diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index 167aa242c1e..6d9b2afec9f 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -442,7 +442,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog features, tracing.InitializeTracerForTest(), sc.db, permreg.ProvidePermissionRegistry(), nil, ) folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( - cfg, features, routing.NewRouteRegister(), sc.db, ac, license, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets, apiserver.WithoutRestConfig) require.NoError(b, err) dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl( sc.cfg, @@ -474,7 +474,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog require.NoError(b, err) _, err = ossaccesscontrol.ProvideDashboardPermissions( - cfg, features, routing.NewRouteRegister(), sc.db, ac, license, dashboardSvc, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets, dashboardSvc) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, dashboardSvc, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets, dashboardSvc, apiserver.WithoutRestConfig) require.NoError(b, err) starSvc := startest.NewStarServiceFake() diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index a9dc71d4d3b..e920bdbec61 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -584,7 +584,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } filestoreService := filestore.ProvideService(inMemory) fileStoreManager := dashboards.ProvideFileStoreManager(pluginstoreService, filestoreService) - folderPermissionsService, err := ossaccesscontrol.ProvideFolderPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, folderimplService, acimplService, teamService, userService, actionSetService) + folderPermissionsService, err := ossaccesscontrol.ProvideFolderPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, folderimplService, acimplService, teamService, userService, actionSetService, eventualRestConfigProvider) if err != nil { return nil, err } @@ -715,7 +715,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService) avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg) prefService := prefimpl.ProvideService(sqlStore, cfg) - dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl) + dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider) if err != nil { return nil, err } @@ -1235,7 +1235,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } filestoreService := filestore.ProvideService(inMemory) fileStoreManager := dashboards.ProvideFileStoreManager(pluginstoreService, filestoreService) - folderPermissionsService, err := ossaccesscontrol.ProvideFolderPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, folderimplService, acimplService, teamService, userService, actionSetService) + folderPermissionsService, err := ossaccesscontrol.ProvideFolderPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, folderimplService, acimplService, teamService, userService, actionSetService, eventualRestConfigProvider) if err != nil { return nil, err } @@ -1368,7 +1368,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService) avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg) prefService := prefimpl.ProvideService(sqlStore, cfg) - dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl) + dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider) if err != nil { return nil, err } diff --git a/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go b/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go index 317585ef8ac..cd4a5a02cd3 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/dashboard.go @@ -4,12 +4,14 @@ import ( "context" "errors" + dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -99,7 +101,7 @@ func ProvideDashboardPermissions( cfg *setting.Cfg, features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, ac accesscontrol.AccessControl, license licensing.Licensing, dashboardService dashboards.DashboardService, folderService folder.Service, service accesscontrol.Service, teamService team.Service, userService user.Service, actionSetService resourcepermissions.ActionSetService, - dashboardPermissionsRegistration dashboards.PermissionsRegistrationService, + dashboardPermissionsRegistration dashboards.PermissionsRegistrationService, restConfigProvider apiserver.RestConfigProvider, ) (*DashboardPermissionsService, error) { getDashboard := func(ctx context.Context, orgID int64, resourceID string) (*dashboards.Dashboard, error) { query := &dashboards.GetDashboardQuery{UID: resourceID, OrgID: orgID} @@ -117,6 +119,7 @@ func ProvideDashboardPermissions( options := resourcepermissions.Options{ Resource: "dashboards", ResourceAttribute: "uid", + APIGroup: dashboardv1.APIGroup, ResourceValidator: func(ctx context.Context, orgID int64, resourceID string) error { ctx, span := tracer.Start(ctx, "accesscontrol.ossaccesscontrol.ProvideDashboardPermissions.ResourceValidator") defer span.End() @@ -166,9 +169,10 @@ func ProvideDashboardPermissions( "Edit": getDashboardEditActions(features), "Admin": getDashboardAdminActions(features), }, - ReaderRoleName: "Permission reader", - WriterRoleName: "Permission writer", - RoleGroup: "Dashboards", + ReaderRoleName: "Permission reader", + WriterRoleName: "Permission writer", + RoleGroup: "Dashboards", + RestConfigProvider: restConfigProvider, } srv, err := resourcepermissions.New(cfg, options, features, router, license, ac, service, sql, teamService, userService, actionSetService) diff --git a/pkg/services/accesscontrol/ossaccesscontrol/folder.go b/pkg/services/accesscontrol/ossaccesscontrol/folder.go index f62b67ce027..3f307146cab 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/folder.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/folder.go @@ -4,12 +4,14 @@ import ( "context" "errors" + folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -90,6 +92,7 @@ func ProvideFolderPermissions( cfg *setting.Cfg, features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, accesscontrol accesscontrol.AccessControl, license licensing.Licensing, folderService folder.Service, service accesscontrol.Service, teamService team.Service, userService user.Service, actionSetService resourcepermissions.ActionSetService, + restConfigProvider apiserver.RestConfigProvider, ) (*FolderPermissionsService, error) { if err := registerFolderRoles(cfg, features, service); err != nil { return nil, err @@ -98,6 +101,7 @@ func ProvideFolderPermissions( options := resourcepermissions.Options{ Resource: "folders", ResourceAttribute: "uid", + APIGroup: folderv1.APIGroup, ResourceValidator: func(ctx context.Context, orgID int64, resourceID string) error { ctx, span := tracer.Start(ctx, "accesscontrol.ossaccesscontrol.ProvideFolderPermissions.ResourceValidator") defer span.End() @@ -139,9 +143,10 @@ func ProvideFolderPermissions( "Edit": append(getDashboardEditActions(features), FolderEditActions...), "Admin": append(getDashboardAdminActions(features), FolderAdminActions...), }, - ReaderRoleName: "Permission reader", - WriterRoleName: "Permission writer", - RoleGroup: "Folders", + ReaderRoleName: "Permission reader", + WriterRoleName: "Permission writer", + RoleGroup: "Folders", + RestConfigProvider: restConfigProvider, } srv, err := resourcepermissions.New(cfg, options, features, router, license, accesscontrol, service, sql, teamService, userService, actionSetService) if err != nil { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go b/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go index 920d7b826d0..2bffa06e546 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go @@ -93,5 +93,6 @@ func ProvideFolderPermissions( teamSvc, userSvc, actionSets, + apiserver.WithoutRestConfig, ) } diff --git a/pkg/services/accesscontrol/resourcepermissions/api.go b/pkg/services/accesscontrol/resourcepermissions/api.go index ac8c30a0d26..981a99f5189 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api.go +++ b/pkg/services/accesscontrol/resourcepermissions/api.go @@ -11,8 +11,11 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" @@ -23,20 +26,22 @@ import ( var tracer = otel.Tracer("github.com/grafana/grafana/pkg/accesscontrol/resourcepermissions") type api struct { - cfg *setting.Cfg - ac accesscontrol.AccessControl - router routing.RouteRegister - service *Service - permissions []string + cfg *setting.Cfg + ac accesscontrol.AccessControl + router routing.RouteRegister + service *Service + permissions []string + features featuremgmt.FeatureToggles + restConfigProvider apiserver.RestConfigProvider } -func newApi(cfg *setting.Cfg, ac accesscontrol.AccessControl, router routing.RouteRegister, manager *Service) *api { +func newApi(cfg *setting.Cfg, ac accesscontrol.AccessControl, router routing.RouteRegister, manager *Service, features featuremgmt.FeatureToggles, restConfigProvider apiserver.RestConfigProvider) *api { permissions := make([]string, 0, len(manager.permissions)) // reverse the permissions order for display for i := len(manager.permissions) - 1; i >= 0; i-- { permissions = append(permissions, manager.permissions[i]) } - return &api{cfg, ac, router, manager, permissions} + return &api{cfg, ac, router, manager, permissions, features, restConfigProvider} } func (a *api) registerEndpoints() { @@ -176,6 +181,22 @@ func (a *api) getPermissions(c *contextmodel.ReqContext) response.Response { resourceID := web.Params(c.Req)[":resourceID"] + //nolint:staticcheck // not yet migrated to OpenFeature + if a.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthZHandlerRedirect) && + a.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { + k8sPermissions, err := a.getResourcePermissionsFromK8s(c.Req.Context(), c.Namespace, resourceID) + if err == nil { + return response.JSON(http.StatusOK, k8sPermissions) + } + span.RecordError(err) + logger := log.New("resource-permissions-api") + if errors.Is(err, ErrRestConfigNotAvailable) { + logger.Debug("k8s API not available for resource permissions, falling back to legacy", "error", err, "resourceID", resourceID, "resource", a.service.options.Resource) + } else { + logger.Warn("Failed to get resource permissions from k8s API, falling back to legacy", "error", err, "resourceID", resourceID, "resource", a.service.options.Resource) + } + } + permissions, err := a.service.GetPermissions(c.Req.Context(), c.SignedInUser, resourceID) if err != nil { return response.ErrOrFallback(http.StatusInternalServerError, "Failed to get permissions", err) diff --git a/pkg/services/accesscontrol/resourcepermissions/api_adapter.go b/pkg/services/accesscontrol/resourcepermissions/api_adapter.go new file mode 100644 index 00000000000..868ae1a32b5 --- /dev/null +++ b/pkg/services/accesscontrol/resourcepermissions/api_adapter.go @@ -0,0 +1,164 @@ +package resourcepermissions + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/authlib/types" + "golang.org/x/text/cases" + "golang.org/x/text/language" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/team" + "github.com/grafana/grafana/pkg/services/user" +) + +var ErrRestConfigNotAvailable = errors.New("k8s rest config provider not available") + +func (a *api) getDynamicClient(ctx context.Context) (dynamic.Interface, error) { + if a.restConfigProvider == nil { + return nil, ErrRestConfigNotAvailable + } + + restConfig, err := a.restConfigProvider.GetRestConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get rest config: %w", err) + } + + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + + return dynamicClient, nil +} + +func (a *api) getResourcePermissionsFromK8s(ctx context.Context, namespace string, resourceID string) (getResourcePermissionsResponse, error) { + dynamicClient, err := a.getDynamicClient(ctx) + if err != nil { + return nil, err + } + + resourcePermName := a.buildResourcePermissionName(resourceID) + + resourcePermResource := dynamicClient.Resource(iamv0.ResourcePermissionInfo.GroupVersionResource()).Namespace(namespace) + unstructuredObj, err := resourcePermResource.Get(ctx, resourcePermName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return getResourcePermissionsResponse{}, nil + } + return nil, fmt.Errorf("failed to get resource permission from k8s: %w", err) + } + + var resourcePerm iamv0.ResourcePermission + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, &resourcePerm); err != nil { + return nil, fmt.Errorf("failed to convert to typed resource permission: %w", err) + } + + return a.convertK8sResourcePermissionToDTO(&resourcePerm, namespace) +} + +func (a *api) convertK8sResourcePermissionToDTO(resourcePerm *iamv0.ResourcePermission, namespace string) (getResourcePermissionsResponse, error) { + permissions := resourcePerm.Spec.Permissions + if len(permissions) == 0 { + return getResourcePermissionsResponse{}, nil + } + + namespaceInfo, err := types.ParseNamespace(namespace) + if err != nil { + return nil, fmt.Errorf("failed to parse namespace %q: %w", namespace, err) + } + orgID := namespaceInfo.OrgID + + dto := make(getResourcePermissionsResponse, 0, len(permissions)) + + for _, perm := range permissions { + kind := perm.Kind + name := perm.Name + verb := perm.Verb + + if name == "" || verb == "" { + continue + } + + permission := cases.Title(language.Und).String(verb) + actions, exists := a.service.options.PermissionsToActions[permission] + if !exists { + log.New("resource-permissions-api").Warn( + "Permission not found in PermissionsToActions map", + "permission", permission, + "resource", a.service.options.Resource, + "availablePermissions", fmt.Sprintf("%v", getMapKeys(a.service.options.PermissionsToActions)), + ) + actions = []string{} + } + + permDTO := resourcePermissionDTO{ + Permission: permission, + Actions: actions, + IsManaged: true, + IsInherited: false, + } + + switch kind { + case iamv0.ResourcePermissionSpecPermissionKindUser, iamv0.ResourcePermissionSpecPermissionKindServiceAccount: + userDetails, err := a.service.userService.GetByUID(context.Background(), &user.GetUserByUIDQuery{UID: name}) + if err == nil { + permDTO.UserID = userDetails.ID + permDTO.UserUID = userDetails.UID + permDTO.UserLogin = userDetails.Login + permDTO.UserAvatarUrl = dtos.GetGravatarUrl(a.cfg, userDetails.Email) + permDTO.IsServiceAccount = userDetails.IsServiceAccount + permDTO.RoleName = fmt.Sprintf("managed:users:%d:permissions", userDetails.ID) + } + case iamv0.ResourcePermissionSpecPermissionKindTeam: + teamDetails, err := a.service.teamService.GetTeamByID(context.Background(), &team.GetTeamByIDQuery{ + UID: name, + OrgID: orgID, + }) + if err == nil { + permDTO.Team = teamDetails.Name + permDTO.TeamID = teamDetails.ID + permDTO.TeamUID = teamDetails.UID + permDTO.TeamAvatarUrl = dtos.GetGravatarUrlWithDefault(a.cfg, teamDetails.Email, teamDetails.Name) + permDTO.RoleName = fmt.Sprintf("managed:teams:%d:permissions", teamDetails.ID) + } else { + permDTO.TeamUID = name + permDTO.Team = name + } + case iamv0.ResourcePermissionSpecPermissionKindBasicRole: + permDTO.BuiltInRole = name + permDTO.RoleName = fmt.Sprintf("managed:builtins:%s:permissions", name) + } + + dto = append(dto, permDTO) + } + + return dto, nil +} + +func (a *api) getAPIGroup() string { + if a.service.options.APIGroup != "" { + return a.service.options.APIGroup + } + return fmt.Sprintf("%s.grafana.app", a.service.options.Resource) +} + +func getMapKeys(m map[string][]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func (a *api) buildResourcePermissionName(resourceID string) string { + return fmt.Sprintf("%s-%s-%s", a.getAPIGroup(), a.service.options.Resource, resourceID) +} diff --git a/pkg/services/accesscontrol/resourcepermissions/options.go b/pkg/services/accesscontrol/resourcepermissions/options.go index 01d40b5834e..51654764620 100644 --- a/pkg/services/accesscontrol/resourcepermissions/options.go +++ b/pkg/services/accesscontrol/resourcepermissions/options.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/web" ) @@ -16,6 +17,9 @@ type Options struct { Resource string // ResourceAttribute is the attribute the scope should be based on (e.g. id or uid) ResourceAttribute string + // APIGroup is the Kubernetes API group for the resource (e.g. "folder.grafana.app") + // If not set, defaults to "{Resource}.grafana.app" + APIGroup string // OnlyManaged will tell the service to return all permissions if set to false and only managed permissions if set to true OnlyManaged bool // ResourceTranslator is a translator function that will be called before each action, it can be used to translate a resource id to a different format. @@ -45,4 +49,6 @@ type Options struct { InheritedScopesSolver InheritedScopesSolver // LicenseMV if configured is applied to endpoints that can modify permissions LicenseMW web.Handler + // RestConfigProvider if configured enables K8s API redirect for resource permissions + RestConfigProvider apiserver.RestConfigProvider } diff --git a/pkg/services/accesscontrol/resourcepermissions/service.go b/pkg/services/accesscontrol/resourcepermissions/service.go index 0866967bdd4..f3a8a8d30b4 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service.go +++ b/pkg/services/accesscontrol/resourcepermissions/service.go @@ -104,7 +104,7 @@ func New(cfg *setting.Cfg, actionSetSvc: actionSetService, } - s.api = newApi(cfg, ac, router, s) + s.api = newApi(cfg, ac, router, s, features, s.options.RestConfigProvider) if err := s.declareFixedRoles(); err != nil { return nil, err