Merge branch 'main' into kristina/static-transform-refIds
This commit is contained in:
@@ -33,9 +33,13 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: github-prerelease-writer@grafanalabs-workload-identity.iam.gserviceaccount.com
|
||||
runs-on:
|
||||
type: string
|
||||
required: false
|
||||
default: github-hosted-ubuntu-x64-small
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: github-hosted-ubuntu-x64-small
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
name: Publish
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
@@ -130,6 +130,9 @@ profile.cov
|
||||
/public/app/extensions
|
||||
!/public/app/extensions/.keep
|
||||
|
||||
# Enterprise operators
|
||||
/pkg/operators/enterprise_*
|
||||
/pkg/operators/**/enterprise_*
|
||||
|
||||
debug.test
|
||||
/examples/*/dist
|
||||
|
||||
@@ -40,21 +40,21 @@ preferencesV1alpha1: {
|
||||
|
||||
// Navigation preferences
|
||||
navbar?: #NavbarPreference
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
|
||||
#QueryHistoryPreference: {
|
||||
// one of: '' | 'query' | 'starred';
|
||||
homeTab?: string
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
|
||||
#CookiePreferences: {
|
||||
analytics?: {}
|
||||
performance?: {}
|
||||
functional?: {}
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
|
||||
#NavbarPreference: {
|
||||
bookmarkUrls: [...string]
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (c *PreferencesClient) Patch(ctx context.Context, identifier resource.Ident
|
||||
return c.client.Patch(ctx, identifier, req, opts)
|
||||
}
|
||||
|
||||
func (c *PreferencesClient) UpdateStatus(ctx context.Context, newStatus PreferencesStatus, opts resource.UpdateOptions) (*Preferences, error) {
|
||||
func (c *PreferencesClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus PreferencesStatus, opts resource.UpdateOptions) (*Preferences, error) {
|
||||
return c.client.Update(ctx, &Preferences{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: PreferencesKind().Kind(),
|
||||
@@ -84,6 +84,8 @@ func (c *PreferencesClient) UpdateStatus(ctx context.Context, newStatus Preferen
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
ResourceVersion: opts.ResourceVersion,
|
||||
Namespace: identifier.Namespace,
|
||||
Name: identifier.Name,
|
||||
},
|
||||
Status: newStatus,
|
||||
}, resource.UpdateOptions{
|
||||
|
||||
@@ -76,7 +76,7 @@ func (c *StarsClient) Patch(ctx context.Context, identifier resource.Identifier,
|
||||
return c.client.Patch(ctx, identifier, req, opts)
|
||||
}
|
||||
|
||||
func (c *StarsClient) UpdateStatus(ctx context.Context, newStatus StarsStatus, opts resource.UpdateOptions) (*Stars, error) {
|
||||
func (c *StarsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus StarsStatus, opts resource.UpdateOptions) (*Stars, error) {
|
||||
return c.client.Update(ctx, &Stars{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: StarsKind().Kind(),
|
||||
@@ -84,6 +84,8 @@ func (c *StarsClient) UpdateStatus(ctx context.Context, newStatus StarsStatus, o
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
ResourceVersion: opts.ResourceVersion,
|
||||
Namespace: identifier.Namespace,
|
||||
Name: identifier.Name,
|
||||
},
|
||||
Status: newStatus,
|
||||
}, resource.UpdateOptions{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
"github.com/grafana/grafana-app-sdk/resource"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
|
||||
v1alpha1 "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
|
||||
)
|
||||
@@ -66,6 +67,10 @@ var appManifestData = app.ManifestData{
|
||||
Schema: &versionSchemaStarsv1alpha1,
|
||||
},
|
||||
},
|
||||
Routes: app.ManifestVersionRoutes{
|
||||
Namespaced: map[string]spec3.PathProps{},
|
||||
Cluster: map[string]spec3.PathProps{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -95,6 +100,7 @@ var customRouteToGoResponseType = map[string]any{}
|
||||
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
|
||||
// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths.
|
||||
// If there is no association for the provided kind, version, custom route path, and method, exists will return false.
|
||||
// Resource routes (those without a kind) should prefix their route with "<namespace>/" if the route is namespaced (otherwise the route is assumed to be cluster-scope)
|
||||
func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) {
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
@@ -113,8 +119,22 @@ func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goTyp
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
var customRouteToGoRequestBodyType = map[string]any{}
|
||||
|
||||
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
|
||||
if len(path) > 0 && path[0] == '/' {
|
||||
path = path[1:]
|
||||
}
|
||||
goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))]
|
||||
return goType, exists
|
||||
}
|
||||
|
||||
type GoTypeAssociator struct{}
|
||||
|
||||
func NewGoTypeAssociator() *GoTypeAssociator {
|
||||
return &GoTypeAssociator{}
|
||||
}
|
||||
|
||||
func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) {
|
||||
return ManifestGoTypeAssociator(kind, version)
|
||||
}
|
||||
@@ -124,3 +144,6 @@ func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb str
|
||||
func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) {
|
||||
return ManifestCustomRouteQueryAssociator(kind, version, path, verb)
|
||||
}
|
||||
func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) {
|
||||
return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ var serviceIdentityTokenPermissions = []string{
|
||||
"secret.grafana.app:*",
|
||||
"query.grafana.app:*",
|
||||
"iam.grafana.app:*",
|
||||
"preferences.grafana.app:*",
|
||||
|
||||
// Secrets Manager uses a custom verb for secret decryption, and its authorizer does not allow wildcard permissions.
|
||||
"secret.grafana.app/securevalues:decrypt",
|
||||
|
||||
@@ -33,11 +33,13 @@ import (
|
||||
|
||||
// provisioningControllerConfig contains the configuration that overlaps for the jobs and repo controllers
|
||||
type provisioningControllerConfig struct {
|
||||
provisioningClient *client.Clientset
|
||||
resyncInterval time.Duration
|
||||
repoFactory repository.Factory
|
||||
unified resources.ResourceStore
|
||||
clients resources.ClientFactory
|
||||
provisioningClient *client.Clientset
|
||||
resyncInterval time.Duration
|
||||
repoFactory repository.Factory
|
||||
unified resources.ResourceStore
|
||||
clients resources.ClientFactory
|
||||
tokenExchangeClient *authn.TokenExchangeClient
|
||||
tlsConfig rest.TLSClientConfig
|
||||
}
|
||||
|
||||
// expects:
|
||||
@@ -179,11 +181,13 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll
|
||||
clients := resources.NewClientFactoryForMultipleAPIServers(configProviders)
|
||||
|
||||
return &provisioningControllerConfig{
|
||||
provisioningClient: provisioningClient,
|
||||
repoFactory: repoFactory,
|
||||
unified: unified,
|
||||
clients: clients,
|
||||
resyncInterval: operatorSec.Key("resync_interval").MustDuration(60 * time.Second),
|
||||
provisioningClient: provisioningClient,
|
||||
repoFactory: repoFactory,
|
||||
unified: unified,
|
||||
clients: clients,
|
||||
resyncInterval: operatorSec.Key("resync_interval").MustDuration(60 * time.Second),
|
||||
tokenExchangeClient: tokenExchangeClient,
|
||||
tlsConfig: tlsConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/migrate"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/move"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/sync"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
"github.com/grafana/grafana/pkg/server"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/controller"
|
||||
informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
deletepkg "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/delete"
|
||||
)
|
||||
|
||||
func RunJobController(deps server.OperatorDependencies) error {
|
||||
logger := logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})).With("logger", "provisioning-job-controller")
|
||||
logger.Info("Starting provisioning job controller")
|
||||
|
||||
tracingConfig, err := tracing.ProvideTracingConfig(deps.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to provide tracing config: %w", err)
|
||||
}
|
||||
|
||||
tracer, err := tracing.ProvideService(tracingConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to provide tracing service: %w", err)
|
||||
}
|
||||
|
||||
controllerCfg, err := setupJobsControllerFromConfig(deps.Config, deps.Registerer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup operator: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigChan
|
||||
fmt.Println("Received shutdown signal, stopping controllers")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Jobs informer and controller (resync ~60s like in register.go)
|
||||
jobInformerFactory := informer.NewSharedInformerFactoryWithOptions(
|
||||
controllerCfg.provisioningClient,
|
||||
controllerCfg.resyncInterval,
|
||||
)
|
||||
jobInformer := jobInformerFactory.Provisioning().V0alpha1().Jobs()
|
||||
jobController, err := controller.NewJobController(jobInformer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create job controller: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("jobs controller started")
|
||||
|
||||
var startHistoryInformers func()
|
||||
if controllerCfg.historyExpiration > 0 {
|
||||
// History jobs informer and controller (separate factory with resync == expiration)
|
||||
historyInformerFactory := informer.NewSharedInformerFactoryWithOptions(
|
||||
controllerCfg.provisioningClient,
|
||||
controllerCfg.historyExpiration,
|
||||
)
|
||||
historyJobInformer := historyInformerFactory.Provisioning().V0alpha1().HistoricJobs()
|
||||
_, err = controller.NewHistoryJobController(
|
||||
controllerCfg.provisioningClient.ProvisioningV0alpha1(),
|
||||
historyJobInformer,
|
||||
controllerCfg.historyExpiration,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create history job controller: %w", err)
|
||||
}
|
||||
logger.Info("history cleanup enabled", "expiration", controllerCfg.historyExpiration.String())
|
||||
startHistoryInformers = func() { historyInformerFactory.Start(ctx.Done()) }
|
||||
} else {
|
||||
startHistoryInformers = func() {}
|
||||
}
|
||||
// HistoryWriter can be either Loki or the API server
|
||||
// TODO: Loki configuration and setup in the same way we do for the API server
|
||||
// https://github.com/grafana/git-ui-sync-project/issues/508
|
||||
// var jobHistoryWriter jobs.HistoryWriter
|
||||
// if b.jobHistoryLoki != nil {
|
||||
// jobHistoryWriter = b.jobHistoryLoki
|
||||
// } else {
|
||||
// jobHistoryWriter = jobs.NewAPIClientHistoryWriter(provisioningClient.ProvisioningV0alpha1())
|
||||
// }
|
||||
|
||||
jobHistoryWriter := jobs.NewAPIClientHistoryWriter(controllerCfg.provisioningClient.ProvisioningV0alpha1())
|
||||
jobStore, err := jobs.NewJobStore(controllerCfg.provisioningClient.ProvisioningV0alpha1(), 30*time.Second, deps.Registerer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create API client job store: %w", err)
|
||||
}
|
||||
|
||||
workers, err := setupWorkers(controllerCfg, deps.Registerer, tracer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setup workers: %w", err)
|
||||
}
|
||||
|
||||
repoGetter := resources.NewRepositoryGetter(
|
||||
controllerCfg.repoFactory,
|
||||
controllerCfg.provisioningClient.ProvisioningV0alpha1(),
|
||||
)
|
||||
|
||||
// This is basically our own JobQueue system
|
||||
driver, err := jobs.NewConcurrentJobDriver(
|
||||
controllerCfg.concurrentDrivers,
|
||||
controllerCfg.maxJobTimeout,
|
||||
controllerCfg.cleanupInterval,
|
||||
controllerCfg.jobInterval,
|
||||
controllerCfg.leaseRenewalInterval,
|
||||
jobStore,
|
||||
repoGetter,
|
||||
jobHistoryWriter,
|
||||
jobController.InsertNotifications(),
|
||||
deps.Registerer,
|
||||
workers...,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create concurrent job driver: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info("jobs controller started")
|
||||
if err := driver.Run(ctx); err != nil {
|
||||
logger.Error("job driver failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start informers
|
||||
go jobInformerFactory.Start(ctx.Done())
|
||||
go startHistoryInformers()
|
||||
|
||||
// Optionally wait for job cache sync; history cleanup can rely on resync events
|
||||
if !cache.WaitForCacheSync(ctx.Done(), jobInformer.Informer().HasSynced) {
|
||||
return fmt.Errorf("failed to sync job informer cache")
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
type jobsControllerConfig struct {
|
||||
provisioningControllerConfig
|
||||
historyExpiration time.Duration
|
||||
maxJobTimeout time.Duration
|
||||
cleanupInterval time.Duration
|
||||
jobInterval time.Duration
|
||||
leaseRenewalInterval time.Duration
|
||||
concurrentDrivers int
|
||||
}
|
||||
|
||||
func setupJobsControllerFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (*jobsControllerConfig, error) {
|
||||
controllerCfg, err := setupFromConfig(cfg, registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &jobsControllerConfig{
|
||||
provisioningControllerConfig: *controllerCfg,
|
||||
historyExpiration: cfg.SectionWithEnvOverrides("operator").Key("history_expiration").MustDuration(0),
|
||||
concurrentDrivers: cfg.SectionWithEnvOverrides("operator").Key("concurrent_drivers").MustInt(3),
|
||||
maxJobTimeout: cfg.SectionWithEnvOverrides("operator").Key("max_job_timeout").MustDuration(20 * time.Minute),
|
||||
cleanupInterval: cfg.SectionWithEnvOverrides("operator").Key("cleanup_interval").MustDuration(time.Minute),
|
||||
jobInterval: cfg.SectionWithEnvOverrides("operator").Key("job_interval").MustDuration(30 * time.Second),
|
||||
leaseRenewalInterval: cfg.SectionWithEnvOverrides("operator").Key("lease_renewal_interval").MustDuration(30 * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setupWorkers(controllerCfg *jobsControllerConfig, registry prometheus.Registerer, tracer tracing.Tracer) ([]jobs.Worker, error) {
|
||||
clients := controllerCfg.clients
|
||||
parsers := resources.NewParserFactory(clients)
|
||||
resourceLister := resources.NewResourceLister(controllerCfg.unified)
|
||||
repositoryResources := resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister)
|
||||
statusPatcher := controller.NewRepositoryStatusPatcher(controllerCfg.provisioningClient.ProvisioningV0alpha1())
|
||||
|
||||
workers := make([]jobs.Worker, 0)
|
||||
|
||||
metrics := jobs.RegisterJobMetrics(registry)
|
||||
|
||||
// Sync
|
||||
syncer := sync.NewSyncer(sync.Compare, sync.FullSync, sync.IncrementalSync, tracer)
|
||||
syncWorker := sync.NewSyncWorker(
|
||||
clients,
|
||||
repositoryResources,
|
||||
nil, // HACK: we have updated the worker to check for nil
|
||||
statusPatcher.Patch,
|
||||
syncer,
|
||||
metrics,
|
||||
tracer,
|
||||
)
|
||||
workers = append(workers, syncWorker)
|
||||
|
||||
// Export
|
||||
stageIfPossible := repository.WrapWithStageAndPushIfPossible
|
||||
exportWorker := export.NewExportWorker(
|
||||
clients,
|
||||
repositoryResources,
|
||||
export.ExportAll,
|
||||
stageIfPossible,
|
||||
metrics,
|
||||
)
|
||||
workers = append(workers, exportWorker)
|
||||
|
||||
// Migrate
|
||||
cleaner := migrate.NewNamespaceCleaner(clients)
|
||||
unifiedStorageMigrator := migrate.NewUnifiedStorageMigrator(
|
||||
cleaner,
|
||||
exportWorker,
|
||||
syncWorker,
|
||||
)
|
||||
migrationWorker := migrate.NewMigrationWorkerFromUnified(unifiedStorageMigrator)
|
||||
workers = append(workers, migrationWorker)
|
||||
|
||||
// Delete
|
||||
deleteWorker := deletepkg.NewWorker(syncWorker, stageIfPossible, repositoryResources, metrics)
|
||||
workers = append(workers, deleteWorker)
|
||||
|
||||
// Move
|
||||
moveWorker := move.NewWorker(syncWorker, stageIfPossible, repositoryResources, metrics)
|
||||
workers = append(workers, moveWorker)
|
||||
|
||||
return workers, nil
|
||||
}
|
||||
@@ -7,12 +7,6 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
server.RegisterOperator(server.Operator{
|
||||
Name: "provisioning-jobs",
|
||||
Description: "Watch provisioning jobs and manage job history cleanup",
|
||||
RunFunc: provisioning.RunJobController,
|
||||
})
|
||||
|
||||
server.RegisterOperator(server.Operator{
|
||||
Name: "provisioning-repo",
|
||||
Description: "Watch provisioning repositories",
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
|
||||
@@ -112,6 +111,10 @@ func (s *folderStorage) Create(ctx context.Context,
|
||||
|
||||
parentUid := accessor.GetFolder()
|
||||
|
||||
// TODO: once the feature flag kubernetesAuthzResourcePermissionApis is removed AND the frontend is calling
|
||||
// /apis directly (to set AnnoKeyGrantPermissions on root level folders), the below should be removed
|
||||
// and we should instead initialize resourcePermissionsSvc in the RegisterAPIService function
|
||||
// and rely on StorageOptions.Permissions.
|
||||
err = s.setDefaultFolderPermissions(ctx, info.OrgID, user, p.Name, parentUid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -133,22 +136,11 @@ func (s *folderStorage) Update(ctx context.Context,
|
||||
|
||||
// GracefulDeleter
|
||||
func (s *folderStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
obj, async, err := s.store.Delete(ctx, name, deleteValidation, options)
|
||||
if err != nil {
|
||||
return obj, async, err
|
||||
}
|
||||
|
||||
if accessErr := s.folderPermissionsSvc.DeleteResourcePermissions(ctx, info.OrgID, name); accessErr != nil {
|
||||
// TODO: add a proper logger to this struct.
|
||||
logger := log.New().FromContext(ctx)
|
||||
logger.Warn("failed to delete folder permission after successfully deleting folder resource", "folder", name, "error", accessErr)
|
||||
}
|
||||
|
||||
return obj, async, err
|
||||
}
|
||||
|
||||
@@ -157,7 +149,7 @@ func (s *folderStorage) DeleteCollection(ctx context.Context, deleteValidation r
|
||||
return nil, fmt.Errorf("DeleteCollection for folders not implemented")
|
||||
}
|
||||
|
||||
func (s *folderStorage) setDefaultFolderPermissions(ctx context.Context, orgID int64, user identity.Requester, uid string, parentUID string) error {
|
||||
func (s *folderStorage) setDefaultFolderPermissions(ctx context.Context, orgID int64, user identity.Requester, uid, parentUID string) error {
|
||||
var permissions []accesscontrol.SetResourcePermissionCommand
|
||||
|
||||
if user.IsIdentityType(claims.TypeUser, claims.TypeServiceAccount) {
|
||||
|
||||
@@ -2,13 +2,18 @@ package folders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
// K8S docs say "Almost nobody should use this hook" about the "begin" hooks, but we do because we only need to
|
||||
@@ -77,10 +82,35 @@ func (b *FolderAPIBuilder) afterDelete(obj runtime.Object, _ *metav1.DeleteOptio
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("Propagating deleted folder to Zanzana", "folder", meta.GetName(), "parent", meta.GetFolder())
|
||||
err = b.permissionStore.DeleteFolderParents(ctx, meta.GetNamespace(), meta.GetName())
|
||||
if err != nil {
|
||||
log.Warn("failed to propagate folder to zanzana", "err", err)
|
||||
if b.features.IsEnabledGlobally(featuremgmt.FlagZanzana) {
|
||||
log.Info("Propagating deleted folder to Zanzana", "folder", meta.GetName(), "parent", meta.GetFolder())
|
||||
err = b.permissionStore.DeleteFolderParents(ctx, meta.GetNamespace(), meta.GetName())
|
||||
if err != nil {
|
||||
log.Warn("failed to propagate folder to zanzana", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if b.resourcePermissionsSvc != nil {
|
||||
log.Debug("deleting folder permissions", "uid", meta.GetName(), "namespace", meta.GetNamespace())
|
||||
client := (*b.resourcePermissionsSvc).Namespace(meta.GetNamespace())
|
||||
err := client.Delete(ctx, fmt.Sprintf("%s-%s-%s", folders.FolderResourceInfo.GroupVersionResource().Group, folders.FolderResourceInfo.GroupVersionResource().Resource, meta.GetName()), metav1.DeleteOptions{})
|
||||
if err != nil {
|
||||
log.Error("failed to delete folder permissions", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: once the feature flag kubernetesAuthzResourcePermissionApis is removed, we should initialize resourcePermissionsSvc
|
||||
// in the RegisterAPIService function and the below should be removed
|
||||
if !util.IsInterfaceNil(b.folderPermissionsSvc) {
|
||||
ns, err := claims.ParseNamespace(meta.GetNamespace())
|
||||
if err != nil {
|
||||
log.Error("failed to parse namespace", "error", err)
|
||||
return
|
||||
}
|
||||
if accessErr := b.folderPermissionsSvc.DeleteResourcePermissions(ctx, ns.OrgID, meta.GetName()); accessErr != nil {
|
||||
log.Warn("failed to delete folder permission after successfully deleting folder resource", "folder", meta.GetName(), "error", accessErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
|
||||
@@ -22,8 +24,10 @@ import (
|
||||
|
||||
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/apps/iam/pkg/reconcilers"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
@@ -54,10 +58,11 @@ type FolderAPIBuilder struct {
|
||||
permissionsOnCreate bool
|
||||
|
||||
// Legacy services -- these will not exist in the MT environment
|
||||
folderSvc folder.LegacyService
|
||||
folderPermissionsSvc accesscontrol.FolderPermissionsService
|
||||
acService accesscontrol.Service
|
||||
ac accesscontrol.AccessControl
|
||||
folderSvc folder.LegacyService
|
||||
resourcePermissionsSvc *dynamic.NamespaceableResourceInterface
|
||||
folderPermissionsSvc accesscontrol.FolderPermissionsService // TODO: Remove this once kubernetesAuthzResourcePermissionApis is removed and the frontend is calling /apis directly to create root level folders
|
||||
acService accesscontrol.Service
|
||||
ac accesscontrol.AccessControl
|
||||
}
|
||||
|
||||
func RegisterAPIService(cfg *setting.Cfg,
|
||||
@@ -88,12 +93,13 @@ func RegisterAPIService(cfg *setting.Cfg,
|
||||
return builder
|
||||
}
|
||||
|
||||
func NewAPIService(ac authlib.AccessClient, searcher resource.ResourceClient, features featuremgmt.FeatureToggles, zanzanaClient zanzana.Client) *FolderAPIBuilder {
|
||||
func NewAPIService(ac authlib.AccessClient, searcher resource.ResourceClient, features featuremgmt.FeatureToggles, zanzanaClient zanzana.Client, resourcePermissionsSvc *dynamic.NamespaceableResourceInterface) *FolderAPIBuilder {
|
||||
return &FolderAPIBuilder{
|
||||
features: features,
|
||||
accessClient: ac,
|
||||
searcher: searcher,
|
||||
permissionStore: reconcilers.NewZanzanaPermissionStore(zanzanaClient),
|
||||
features: features,
|
||||
accessClient: ac,
|
||||
searcher: searcher,
|
||||
permissionStore: reconcilers.NewZanzanaPermissionStore(zanzanaClient),
|
||||
resourcePermissionsSvc: resourcePermissionsSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +144,9 @@ func (b *FolderAPIBuilder) AllowedV0Alpha1Resources() []string {
|
||||
func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
opts.StorageOptsRegister(resourceInfo.GroupResource(), apistore.StorageOptions{
|
||||
EnableFolderSupport: true,
|
||||
RequireDeprecatedInternalID: true})
|
||||
RequireDeprecatedInternalID: true,
|
||||
Permissions: b.setDefaultFolderPermissions,
|
||||
})
|
||||
|
||||
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, resourceInfo, opts.OptsGetter)
|
||||
if err != nil {
|
||||
@@ -193,17 +201,101 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
|
||||
return nil
|
||||
}
|
||||
|
||||
var defaultPermissions = []map[string]any{
|
||||
{
|
||||
"kind": "BasicRole",
|
||||
"name": "Admin",
|
||||
"verb": "admin",
|
||||
},
|
||||
{
|
||||
"kind": "BasicRole",
|
||||
"name": "Editor",
|
||||
"verb": "edit",
|
||||
},
|
||||
{
|
||||
"kind": "BasicRole",
|
||||
"name": "Viewer",
|
||||
"verb": "view",
|
||||
},
|
||||
}
|
||||
|
||||
func (b *FolderAPIBuilder) setDefaultFolderPermissions(ctx context.Context, key *resourcepb.ResourceKey, id authlib.AuthInfo, obj utils.GrafanaMetaAccessor) error {
|
||||
if b.resourcePermissionsSvc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// only set default permissions for root folders
|
||||
if obj.GetFolder() != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
log := logging.FromContext(ctx)
|
||||
log.Debug("setting default folder permissions", "uid", obj.GetName(), "namespace", obj.GetNamespace())
|
||||
|
||||
client := (*b.resourcePermissionsSvc).Namespace(obj.GetNamespace())
|
||||
name := fmt.Sprintf("%s-%s-%s", folders.FolderResourceInfo.GroupVersionResource().Group, folders.FolderResourceInfo.GroupVersionResource().Resource, obj.GetName())
|
||||
|
||||
// the resource permission will likely already exist with admin can admin, so we will need to update it
|
||||
if _, err := client.Get(ctx, name, metav1.GetOptions{}); err == nil {
|
||||
_, err := client.Update(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]any{
|
||||
"name": name,
|
||||
"namespace": obj.GetNamespace(),
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"resource": map[string]any{
|
||||
"apiGroup": folders.FolderResourceInfo.GroupVersionResource().Group,
|
||||
"resource": folders.FolderResourceInfo.GroupVersionResource().Resource,
|
||||
"name": obj.GetName(),
|
||||
},
|
||||
"permissions": defaultPermissions,
|
||||
},
|
||||
},
|
||||
}, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
logger.Error("failed to update root permissions", "error", err)
|
||||
return fmt.Errorf("update root permissions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := client.Create(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]any{
|
||||
"name": name,
|
||||
"namespace": obj.GetNamespace(),
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"resource": map[string]any{
|
||||
"apiGroup": folders.FolderResourceInfo.GroupVersionResource().Group,
|
||||
"resource": folders.FolderResourceInfo.GroupVersionResource().Resource,
|
||||
"name": obj.GetName(),
|
||||
},
|
||||
"permissions": defaultPermissions,
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
logger.Error("failed to create root permissions", "error", err)
|
||||
return fmt.Errorf("create root permissions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *FolderAPIBuilder) registerPermissionHooks(store *genericregistry.Store) {
|
||||
log := logging.FromContext(context.Background())
|
||||
|
||||
if b.features.IsEnabledGlobally(featuremgmt.FlagZanzana) {
|
||||
log.Info("Enabling Zanzana folder propagation hooks")
|
||||
store.BeginCreate = b.beginCreate
|
||||
store.BeginUpdate = b.beginUpdate
|
||||
store.AfterDelete = b.afterDelete
|
||||
} else {
|
||||
log.Info("Zanzana is not enabled; skipping folder propagation hooks")
|
||||
}
|
||||
|
||||
store.AfterDelete = b.afterDelete
|
||||
}
|
||||
|
||||
func (b *FolderAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
|
||||
@@ -6,55 +6,98 @@ import (
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
)
|
||||
|
||||
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
if !attr.IsResourceRequest() || user.GetIsGrafanaAdmin() || attr.GetName() == "" {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
name, found := utils.ParseOwnerFromName(attr.GetName())
|
||||
if !found {
|
||||
return authorizer.DecisionDeny, "invalid name", nil
|
||||
}
|
||||
|
||||
if attr.GetResource() == "stars" && name.Owner != utils.UserResourceOwner {
|
||||
return authorizer.DecisionDeny, "stars only support users", nil
|
||||
}
|
||||
|
||||
switch name.Owner {
|
||||
case utils.NamespaceResourceOwner:
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
|
||||
case utils.UserResourceOwner:
|
||||
if user.GetUID() == name.Name {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "you may only fetch your own preferences", nil
|
||||
|
||||
case utils.TeamResourceOwner:
|
||||
admin := !attr.IsReadOnly() // we need admin to for non read only commands
|
||||
teams, err := b.sql.GetTeams(ctx, user.GetOrgID(), user.GetUID(), admin)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "error fetching teams", err
|
||||
}
|
||||
if slices.Contains(teams, name.Name) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "not a team member", nil
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
return authorizer.DecisionDeny, "invalid name", nil
|
||||
})
|
||||
type authorizeFromName struct {
|
||||
teams utils.TeamService
|
||||
oknames []string
|
||||
resource map[string][]utils.ResourceOwner // may include unknown
|
||||
}
|
||||
|
||||
func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil || user == nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
owners, ok := a.resource[attr.GetResource()]
|
||||
if !ok {
|
||||
return authorizer.DecisionDeny, "missing resource name", nil
|
||||
}
|
||||
|
||||
// Check if the request includes explicit permissions
|
||||
res := authz.CheckServicePermissions(user, attr.GetAPIGroup(), attr.GetResource(), attr.GetVerb())
|
||||
if !res.Allowed {
|
||||
log := logging.FromContext(ctx)
|
||||
log.Info("calling service lacks required permissions",
|
||||
"isServiceCall", res.ServiceCall,
|
||||
"apiGroup", attr.GetAPIGroup(),
|
||||
"resource", attr.GetResource(),
|
||||
"verb", attr.GetVerb(),
|
||||
"permissions", len(res.Permissions),
|
||||
)
|
||||
return authorizer.DecisionDeny, "calling service lacks required permissions", nil
|
||||
}
|
||||
|
||||
if attr.GetName() == "" {
|
||||
if attr.IsReadOnly() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "mutating request without a name", nil
|
||||
}
|
||||
|
||||
// the pseudo sub-resource
|
||||
if a.oknames != nil && slices.Contains(a.oknames, attr.GetName()) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
info, _ := utils.ParseOwnerFromName(attr.GetName())
|
||||
if !slices.Contains(owners, info.Owner) {
|
||||
return authorizer.DecisionDeny, "unsupported owner type", nil
|
||||
}
|
||||
|
||||
switch info.Owner {
|
||||
case utils.NamespaceResourceOwner:
|
||||
if attr.IsReadOnly() {
|
||||
// Everyone can see the namespace
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
if user.GetOrgRole() == identity.RoleAdmin {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "must be an org admin to edit", nil
|
||||
|
||||
case utils.UserResourceOwner:
|
||||
if user.GetIdentifier() == info.Identifier {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "your are not the owner of the resource", nil
|
||||
|
||||
case utils.TeamResourceOwner:
|
||||
if a.teams == nil {
|
||||
return authorizer.DecisionDeny, "team checker not configured", err
|
||||
}
|
||||
ok, err := a.teams.InTeam(ctx, user, info.Identifier, !attr.IsReadOnly())
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "error fetching teams", err
|
||||
}
|
||||
if ok {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "you are not a member of the referenced team", nil
|
||||
|
||||
case utils.UnknownResourceOwner:
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
// the owner was not explicitly allowed
|
||||
return authorizer.DecisionDeny, "", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package preferences
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
)
|
||||
|
||||
type expect struct {
|
||||
decision authorizer.Decision
|
||||
reason string
|
||||
err string
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
user identity.Requester
|
||||
attrs authorizer.Attributes
|
||||
expect expect
|
||||
breakpoint bool
|
||||
}
|
||||
|
||||
func TestAuthorizer_Authorize(t *testing.T) {
|
||||
userABC := &identity.StaticRequester{
|
||||
UserUID: "abc",
|
||||
OrgRole: identity.RoleViewer,
|
||||
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
|
||||
Rest: authn.AccessTokenClaims{
|
||||
DelegatedPermissions: []string{"group/stars:*", "group/preferences:*", "group/ns:*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
teams func(t *testing.T) utils.TeamService
|
||||
resource map[string][]utils.ResourceOwner
|
||||
check []testCase
|
||||
}{
|
||||
{
|
||||
name: "stars",
|
||||
resource: map[string][]utils.ResourceOwner{
|
||||
"stars": {utils.UserResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "matches user",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
Name: "user-abc", // note this matches in input user name
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "different user",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
Name: "user-xyz", // not abc
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "your are not the owner of the resource",
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "fast path",
|
||||
resource: map[string][]utils.ResourceOwner{
|
||||
"stars": {utils.UserResourceOwner},
|
||||
"preferences": {utils.TeamResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "missing user",
|
||||
attrs: authorizer.AttributesRecord{},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
err: "a Requester was not found in the context",
|
||||
},
|
||||
}, {
|
||||
name: "not a resource",
|
||||
user: &identity.StaticRequester{},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
ResourceRequest: false,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionNoOpinion,
|
||||
},
|
||||
}, {
|
||||
name: "unknown resource",
|
||||
user: &identity.StaticRequester{},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Resource: "xxxx",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "missing resource name",
|
||||
},
|
||||
}, {
|
||||
name: "missing service permissions",
|
||||
user: &identity.StaticRequester{
|
||||
UserUID: "abc",
|
||||
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
|
||||
Rest: authn.AccessTokenClaims{
|
||||
DelegatedPermissions: []string{""},
|
||||
},
|
||||
},
|
||||
},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "calling service lacks required permissions",
|
||||
},
|
||||
}, {
|
||||
name: "wrong owner type",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
Verb: "create", // missing name
|
||||
Name: "team-xxx", // not supported
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "unsupported owner type",
|
||||
},
|
||||
}, {
|
||||
name: "unknown resource",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
Verb: "create", // missing name
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "mutating request without a name",
|
||||
},
|
||||
}, {
|
||||
name: "list request",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
ResourceRequest: true,
|
||||
Verb: "list", // no name
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "teams request (but not configured)",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
ResourceRequest: true,
|
||||
Verb: "get",
|
||||
Name: "team-XYZ",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "team checker not configured",
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "unknown owner",
|
||||
resource: map[string][]utils.ResourceOwner{
|
||||
"stars": {utils.UnknownResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "get",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "stars",
|
||||
Name: "something-not-an-owner",
|
||||
ResourceRequest: true,
|
||||
Verb: "get",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "namespace",
|
||||
resource: map[string][]utils.ResourceOwner{
|
||||
"ns": {utils.NamespaceResourceOwner},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "readonly",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "ns",
|
||||
ResourceRequest: true,
|
||||
Verb: "get",
|
||||
Name: "namespace",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "mutating",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "ns",
|
||||
ResourceRequest: true,
|
||||
Verb: "create",
|
||||
Name: "namespace",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "must be an org admin to edit",
|
||||
},
|
||||
}, {
|
||||
name: "org admin",
|
||||
user: &identity.StaticRequester{
|
||||
UserUID: "abc",
|
||||
OrgRole: identity.RoleAdmin,
|
||||
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
|
||||
Rest: authn.AccessTokenClaims{
|
||||
DelegatedPermissions: []string{"group/ns:create"},
|
||||
},
|
||||
},
|
||||
},
|
||||
attrs: authorizer.AttributesRecord{
|
||||
APIGroup: "group",
|
||||
Resource: "ns",
|
||||
ResourceRequest: true,
|
||||
Verb: "create",
|
||||
Name: "namespace",
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}},
|
||||
}, {
|
||||
name: "preferences teams",
|
||||
teams: func(t *testing.T) utils.TeamService {
|
||||
teams := utils.NewMockTeamService(t)
|
||||
teams.On("InTeam", mock.Anything, userABC, "xyz", false).Return(true, nil)
|
||||
teams.On("InTeam", mock.Anything, userABC, "456", false).Return(false, nil)
|
||||
teams.On("InTeam", mock.Anything, userABC, "XXX", false).Return(true, fmt.Errorf("error from team"))
|
||||
return teams
|
||||
},
|
||||
resource: map[string][]utils.ResourceOwner{
|
||||
"preferences": {
|
||||
utils.TeamResourceOwner,
|
||||
},
|
||||
},
|
||||
check: []testCase{{
|
||||
name: "user in team",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
Name: "team-xyz",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
}, {
|
||||
name: "user not in team",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
Name: "team-456",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "you are not a member of the referenced team",
|
||||
},
|
||||
}, {
|
||||
name: "team error",
|
||||
user: userABC,
|
||||
attrs: authorizer.AttributesRecord{
|
||||
Verb: "get",
|
||||
APIGroup: "group",
|
||||
Resource: "preferences",
|
||||
Name: "team-XXX",
|
||||
ResourceRequest: true,
|
||||
},
|
||||
expect: expect{
|
||||
decision: authorizer.DecisionDeny,
|
||||
reason: "error fetching teams",
|
||||
err: "error from team",
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authz := &authorizeFromName{
|
||||
resource: tt.resource,
|
||||
}
|
||||
if tt.teams != nil {
|
||||
authz.teams = tt.teams(t)
|
||||
}
|
||||
for _, check := range tt.check {
|
||||
t.Run(check.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if check.user != nil {
|
||||
ctx = identity.WithRequester(ctx, check.user)
|
||||
}
|
||||
e := check.expect
|
||||
if check.breakpoint {
|
||||
require.True(t, true) // Can set breakpoint in IDE here
|
||||
}
|
||||
d, r, err := authz.Authorize(ctx, check.attrs)
|
||||
if e.err != "" {
|
||||
require.ErrorContains(t, err, e.err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, e.decision, d)
|
||||
if e.reason != "" {
|
||||
require.Equal(t, e.reason, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package legacy
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -85,34 +84,19 @@ func (s *preferenceStorage) Get(ctx context.Context, name string, options *metav
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner, ok := utils.ParseOwnerFromName(name)
|
||||
if !ok {
|
||||
return nil, preferences.PreferencesResourceInfo.NewNotFound(name)
|
||||
}
|
||||
|
||||
// NOTE: the authorizer already checked if this request is allowed
|
||||
found, _, err := s.sql.listPreferences(ctx, ns.Value, ns.OrgID, func(req *preferencesQuery) (bool, error) {
|
||||
switch owner.Owner {
|
||||
case utils.UserResourceOwner:
|
||||
if !user.GetIsGrafanaAdmin() && name != user.GetUID() {
|
||||
return false, fmt.Errorf("you may only fetch your own preferences")
|
||||
}
|
||||
req.UserUID = owner.Name
|
||||
req.UserUID = owner.Identifier
|
||||
return false, nil
|
||||
case utils.TeamResourceOwner:
|
||||
if !user.GetIsGrafanaAdmin() {
|
||||
teams, err := s.sql.GetTeams(ctx, ns.OrgID, user.GetRawIdentifier(), false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !slices.Contains(teams, owner.Name) {
|
||||
return false, fmt.Errorf("you may only fetch teams you belong to")
|
||||
}
|
||||
}
|
||||
req.TeamUID = owner.Name
|
||||
req.TeamUID = owner.Identifier
|
||||
return false, nil
|
||||
case utils.NamespaceResourceOwner:
|
||||
return false, nil
|
||||
@@ -136,13 +120,13 @@ func asPreferencesResource(ns string, p *preferenceModel) preferences.Preference
|
||||
owner := utils.OwnerReference{}
|
||||
if p.TeamUID.Valid {
|
||||
owner.Owner = utils.TeamResourceOwner
|
||||
owner.Name = p.TeamUID.String
|
||||
owner.Identifier = p.TeamUID.String
|
||||
} else if p.UserUID.Valid {
|
||||
owner.Owner = utils.UserResourceOwner
|
||||
owner.Name = p.UserUID.String
|
||||
owner.Identifier = p.UserUID.String
|
||||
} else {
|
||||
owner.Owner = utils.NamespaceResourceOwner
|
||||
owner.Name = ""
|
||||
owner.Identifier = ""
|
||||
}
|
||||
obj := preferences.Preferences{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
|
||||
@@ -230,7 +230,10 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit
|
||||
func(req *preferencesQuery) (bool, error) {
|
||||
if user != nil {
|
||||
req.UserUID = user.GetRawIdentifier()
|
||||
teams, err = s.GetTeams(ctx, info.OrgID, req.UserUID, false)
|
||||
teams, err = s.GetTeams(ctx, &identity.StaticRequester{
|
||||
OrgID: info.OrgID,
|
||||
UserUID: req.UserUID,
|
||||
}, false)
|
||||
req.UserTeams = teams
|
||||
}
|
||||
return needsRV, err
|
||||
@@ -260,13 +263,26 @@ func (s *LegacySQL) ListPreferences(ctx context.Context, ns string, user identit
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *LegacySQL) GetTeams(ctx context.Context, orgId int64, user string, admin bool) ([]string, error) {
|
||||
func (s *LegacySQL) InTeam(ctx context.Context, id authlib.AuthInfo, team string, admin bool) (bool, error) {
|
||||
// Could be faster, but find for now
|
||||
teams, err := s.GetTeams(ctx, id, admin)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return slices.Contains(teams, team), nil
|
||||
}
|
||||
|
||||
func (s *LegacySQL) GetTeams(ctx context.Context, id authlib.AuthInfo, admin bool) ([]string, error) {
|
||||
sql, err := s.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := newTeamsQueryReq(sql, orgId, user, admin)
|
||||
xid, ok := id.(identity.Requester)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected identity.Requester")
|
||||
}
|
||||
req := newTeamsQueryReq(sql, xid.GetOrgID(), id.GetUID(), admin)
|
||||
|
||||
q, err := sqltemplate.Execute(sqlTeams, req)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
dashboardsV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/star"
|
||||
@@ -91,8 +92,18 @@ func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversi
|
||||
return nil, fmt.Errorf("cross cluster listing is not supported")
|
||||
}
|
||||
|
||||
userInfo, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := userInfo.GetUID()
|
||||
if userInfo.GetIsGrafanaAdmin() || userInfo.GetIdentityType() == authlib.TypeAccessPolicy {
|
||||
user = "" // can see everything
|
||||
}
|
||||
|
||||
list := &preferences.StarsList{}
|
||||
found, rv, err := s.sql.GetStars(ctx, ns.OrgID, "")
|
||||
found, rv, err := s.sql.GetStars(ctx, ns.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -126,7 +137,7 @@ func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *m
|
||||
return nil, err
|
||||
}
|
||||
|
||||
found, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Name)
|
||||
found, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -157,7 +168,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star
|
||||
}
|
||||
|
||||
user, err := s.users.GetByUID(ctx, &user.GetUserByUIDQuery{
|
||||
UID: owner.Name,
|
||||
UID: owner.Identifier,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -176,7 +187,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star
|
||||
}}, err
|
||||
}
|
||||
|
||||
current, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Name)
|
||||
current, _, err := s.sql.GetStars(ctx, ns.OrgID, owner.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+15
-12
@@ -18,13 +18,13 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util/errhttp"
|
||||
)
|
||||
|
||||
type calculator struct {
|
||||
type merger struct {
|
||||
defaults preferences.PreferencesSpec
|
||||
sql *legacy.LegacySQL
|
||||
}
|
||||
|
||||
func newCalculator(cfg *setting.Cfg, sql *legacy.LegacySQL) *calculator {
|
||||
return &calculator{
|
||||
func newMerger(cfg *setting.Cfg, sql *legacy.LegacySQL) *merger {
|
||||
return &merger{
|
||||
sql: sql,
|
||||
defaults: preferences.PreferencesSpec{
|
||||
Theme: &cfg.DefaultTheme,
|
||||
@@ -35,17 +35,17 @@ func newCalculator(cfg *setting.Cfg, sql *legacy.LegacySQL) *calculator {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *calculator) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
|
||||
func (s *merger) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
|
||||
schema := defs["github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.Preference"].Schema
|
||||
|
||||
return &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
{
|
||||
Path: "current", // calculate?
|
||||
Path: "preferences/merged",
|
||||
Spec: &spec3.PathProps{
|
||||
Get: &spec3.Operation{
|
||||
OperationProps: spec3.OperationProps{
|
||||
OperationId: "currentPreferences",
|
||||
OperationId: "mergedPreferences",
|
||||
Tags: []string{"Preferences"},
|
||||
Description: "Get preferences for requester. This combines the user preferences with the team and global defaults",
|
||||
Parameters: []*spec3.Parameter{
|
||||
@@ -87,7 +87,7 @@ func (s *calculator) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *bui
|
||||
}
|
||||
}
|
||||
|
||||
func (s *calculator) Current(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *merger) Current(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
@@ -115,15 +115,18 @@ func (s *calculator) Current(w http.ResponseWriter, r *http.Request) {
|
||||
// items should be in ascending order of importance
|
||||
func merge(defaults preferences.PreferencesSpec, items []preferences.Preferences) (*preferences.Preferences, error) {
|
||||
p := &preferences.Preferences{
|
||||
TypeMeta: preferences.PreferencesResourceInfo.TypeMeta(),
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
CreationTimestamp: v1.Now(),
|
||||
},
|
||||
Spec: defaults,
|
||||
TypeMeta: preferences.PreferencesResourceInfo.TypeMeta(),
|
||||
ObjectMeta: v1.ObjectMeta{},
|
||||
Spec: defaults,
|
||||
}
|
||||
|
||||
// Iterate in reverse order (least relevant to most relevant)
|
||||
for _, v := range items {
|
||||
// Set the time from the most recent change
|
||||
if p.CreationTimestamp.IsZero() || v.CreationTimestamp.After(p.CreationTimestamp.Time) {
|
||||
p.CreationTimestamp = v.CreationTimestamp
|
||||
}
|
||||
|
||||
if err := mergo.Merge(&p.Spec, &v.Spec, mergo.WithOverride); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/legacy"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/preferences/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -30,13 +32,11 @@ import (
|
||||
var _ builder.APIGroupBuilder = (*APIBuilder)(nil)
|
||||
|
||||
type APIBuilder struct {
|
||||
namespacer request.NamespaceMapper
|
||||
sql *legacy.LegacySQL
|
||||
authorizer authorizer.Authorizer
|
||||
legacyStars *legacy.DashboardStarsStorage
|
||||
legacyPrefs rest.Storage
|
||||
|
||||
stars star.Service
|
||||
prefs pref.Service
|
||||
users user.Service
|
||||
calculator *calculator // joins all preferences
|
||||
merger *merger // joins all preferences
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
@@ -55,13 +55,29 @@ func RegisterAPIService(
|
||||
|
||||
sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db))
|
||||
builder := &APIBuilder{
|
||||
prefs: prefs, // for writing
|
||||
stars: stars, // for writing
|
||||
users: users, // for writing
|
||||
namespacer: request.GetNamespaceMapper(cfg),
|
||||
sql: sql,
|
||||
calculator: newCalculator(cfg, sql),
|
||||
merger: newMerger(cfg, sql),
|
||||
authorizer: &authorizeFromName{
|
||||
oknames: []string{"merged"},
|
||||
teams: sql, // should be from the IAM service
|
||||
resource: map[string][]utils.ResourceOwner{
|
||||
"stars": {utils.UserResourceOwner},
|
||||
"preferences": {
|
||||
utils.NamespaceResourceOwner,
|
||||
utils.TeamResourceOwner,
|
||||
utils.UserResourceOwner,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
namespacer := request.GetNamespaceMapper(cfg)
|
||||
if prefs != nil {
|
||||
builder.legacyPrefs = legacy.NewPreferencesStorage(namespacer, sql)
|
||||
}
|
||||
if stars != nil {
|
||||
builder.legacyStars = legacy.NewDashboardStarsStorage(stars, users, namespacer, sql)
|
||||
}
|
||||
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
}
|
||||
@@ -92,49 +108,49 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
// Configure Stars Dual writer
|
||||
resource := preferences.StarsResourceInfo
|
||||
var stars grafanarest.Storage
|
||||
unified, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
|
||||
stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stars = unified
|
||||
if b.stars != nil && opts.DualWriteBuilder != nil {
|
||||
legacy := legacy.NewDashboardStarsStorage(b.stars, b.users, b.namespacer, b.sql)
|
||||
stars, err = opts.DualWriteBuilder(resource.GroupResource(), legacy, unified)
|
||||
if b.legacyStars != nil && opts.DualWriteBuilder != nil {
|
||||
stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
storage[resource.StoragePath()] = stars
|
||||
storage[resource.StoragePath("write")] = &starsREST{
|
||||
store: stars,
|
||||
}
|
||||
storage[resource.StoragePath("update")] = &starsREST{store: stars}
|
||||
|
||||
// Configure Preferences
|
||||
prefs := preferences.PreferencesResourceInfo
|
||||
storage[prefs.StoragePath()] = legacy.NewPreferencesStorage(b.namespacer, b.sql)
|
||||
storage[prefs.StoragePath()] = b.legacyPrefs
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[preferences.APIVersion] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return b.authorizer
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return preferences.GetOpenAPIDefinitions
|
||||
}
|
||||
|
||||
func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
|
||||
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
|
||||
return b.calculator.GetAPIRoutes(defs)
|
||||
return b.merger.GetAPIRoutes(defs)
|
||||
}
|
||||
|
||||
func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
||||
oas.Info.Description = "Grafana preferences"
|
||||
|
||||
root := "/apis/" + b.GetGroupVersion().String() + "/"
|
||||
writeKey := root + "namespaces/{namespace}/stars/{name}/write"
|
||||
delete(oas.Paths.Paths, writeKey)
|
||||
updateKey := root + "namespaces/{namespace}/stars/{name}/update"
|
||||
delete(oas.Paths.Paths, updateKey)
|
||||
|
||||
// Add the group/kind/id properties to the path
|
||||
stars, ok := oas.Paths.Paths[writeKey+"/{path}"]
|
||||
stars, ok := oas.Paths.Paths[updateKey+"/{path}"]
|
||||
if !ok || stars == nil {
|
||||
return nil, fmt.Errorf("unable to find write path")
|
||||
}
|
||||
@@ -175,8 +191,8 @@ func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, err
|
||||
stars.Delete.Description = "Remove a starred item"
|
||||
stars.Delete.OperationId = "removeStar"
|
||||
|
||||
delete(oas.Paths.Paths, writeKey+"/{path}")
|
||||
oas.Paths.Paths[writeKey+"/{group}/{kind}/{id}"] = stars
|
||||
delete(oas.Paths.Paths, updateKey+"/{path}")
|
||||
oas.Paths.Paths[updateKey+"/{group}/{kind}/{id}"] = stars
|
||||
|
||||
return oas, nil
|
||||
}
|
||||
|
||||
+2
-2
@@ -64,12 +64,12 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object,
|
||||
if !found || parsed.Owner != utils.UserResourceOwner {
|
||||
return nil, fmt.Errorf("only works with user stars")
|
||||
}
|
||||
if user.GetIdentifier() != parsed.Name {
|
||||
if user.GetIdentifier() != parsed.Identifier {
|
||||
return nil, fmt.Errorf("must request as the given user")
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
item, err := itemFromPath(req.URL.Path, fmt.Sprintf("/%s/write", name))
|
||||
item, err := itemFromPath(req.URL.Path, fmt.Sprintf("/%s/update", name))
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
@@ -17,15 +17,15 @@ const (
|
||||
)
|
||||
|
||||
type OwnerReference struct {
|
||||
Owner ResourceOwner // the resource owner
|
||||
Name string // the team|user name
|
||||
Owner ResourceOwner // the resource owner
|
||||
Identifier string // the team|user name
|
||||
}
|
||||
|
||||
func (o OwnerReference) AsName() string {
|
||||
if o.Name == "" || o.Owner == NamespaceResourceOwner {
|
||||
if o.Identifier == "" || o.Owner == NamespaceResourceOwner {
|
||||
return string(o.Owner)
|
||||
}
|
||||
return string(o.Owner) + "-" + o.Name
|
||||
return string(o.Owner) + "-" + o.Identifier
|
||||
}
|
||||
|
||||
func ParseOwnerFromName(name string) (OwnerReference, bool) {
|
||||
@@ -33,9 +33,9 @@ func ParseOwnerFromName(name string) (OwnerReference, bool) {
|
||||
if found && len(after) > 0 {
|
||||
switch before {
|
||||
case "user":
|
||||
return OwnerReference{Owner: UserResourceOwner, Name: after}, true
|
||||
return OwnerReference{Owner: UserResourceOwner, Identifier: after}, true
|
||||
case "team":
|
||||
return OwnerReference{Owner: TeamResourceOwner, Name: after}, true
|
||||
return OwnerReference{Owner: TeamResourceOwner, Identifier: after}, true
|
||||
}
|
||||
} else if name == "namespace" {
|
||||
return OwnerReference{Owner: NamespaceResourceOwner}, true
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestLegacyAuthorizer(t *testing.T) {
|
||||
{
|
||||
name: "with user",
|
||||
input: "user-a",
|
||||
output: utils.OwnerReference{Owner: utils.UserResourceOwner, Name: "a"},
|
||||
output: utils.OwnerReference{Owner: utils.UserResourceOwner, Identifier: "a"},
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
@@ -36,7 +36,7 @@ func TestLegacyAuthorizer(t *testing.T) {
|
||||
{
|
||||
name: "with team",
|
||||
input: "team-b",
|
||||
output: utils.OwnerReference{Owner: utils.TeamResourceOwner, Name: "b"},
|
||||
output: utils.OwnerReference{Owner: utils.TeamResourceOwner, Identifier: "b"},
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
)
|
||||
|
||||
//go:generate mockery --name TeamService --structname MockTeamService --inpackage --filename teams_mock.go --with-expecter
|
||||
type TeamService interface {
|
||||
InTeam(ctx context.Context, id authlib.AuthInfo, team string, admin bool) (bool, error)
|
||||
GetTeams(ctx context.Context, id authlib.AuthInfo, admin bool) ([]string, error)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
types "github.com/grafana/authlib/types"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockTeamService is an autogenerated mock type for the TeamService type
|
||||
type MockTeamService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockTeamService_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockTeamService) EXPECT() *MockTeamService_Expecter {
|
||||
return &MockTeamService_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// GetTeams provides a mock function with given fields: ctx, id, admin
|
||||
func (_m *MockTeamService) GetTeams(ctx context.Context, id types.AuthInfo, admin bool) ([]string, error) {
|
||||
ret := _m.Called(ctx, id, admin)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetTeams")
|
||||
}
|
||||
|
||||
var r0 []string
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, bool) ([]string, error)); ok {
|
||||
return rf(ctx, id, admin)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, bool) []string); ok {
|
||||
r0 = rf(ctx, id, admin)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]string)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, types.AuthInfo, bool) error); ok {
|
||||
r1 = rf(ctx, id, admin)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockTeamService_GetTeams_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTeams'
|
||||
type MockTeamService_GetTeams_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetTeams is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - id types.AuthInfo
|
||||
// - admin bool
|
||||
func (_e *MockTeamService_Expecter) GetTeams(ctx interface{}, id interface{}, admin interface{}) *MockTeamService_GetTeams_Call {
|
||||
return &MockTeamService_GetTeams_Call{Call: _e.mock.On("GetTeams", ctx, id, admin)}
|
||||
}
|
||||
|
||||
func (_c *MockTeamService_GetTeams_Call) Run(run func(ctx context.Context, id types.AuthInfo, admin bool)) *MockTeamService_GetTeams_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(types.AuthInfo), args[2].(bool))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTeamService_GetTeams_Call) Return(_a0 []string, _a1 error) *MockTeamService_GetTeams_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTeamService_GetTeams_Call) RunAndReturn(run func(context.Context, types.AuthInfo, bool) ([]string, error)) *MockTeamService_GetTeams_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// InTeam provides a mock function with given fields: ctx, id, team, admin
|
||||
func (_m *MockTeamService) InTeam(ctx context.Context, id types.AuthInfo, team string, admin bool) (bool, error) {
|
||||
ret := _m.Called(ctx, id, team, admin)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for InTeam")
|
||||
}
|
||||
|
||||
var r0 bool
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, string, bool) (bool, error)); ok {
|
||||
return rf(ctx, id, team, admin)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, types.AuthInfo, string, bool) bool); ok {
|
||||
r0 = rf(ctx, id, team, admin)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, types.AuthInfo, string, bool) error); ok {
|
||||
r1 = rf(ctx, id, team, admin)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockTeamService_InTeam_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InTeam'
|
||||
type MockTeamService_InTeam_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// InTeam is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - id types.AuthInfo
|
||||
// - team string
|
||||
// - admin bool
|
||||
func (_e *MockTeamService_Expecter) InTeam(ctx interface{}, id interface{}, team interface{}, admin interface{}) *MockTeamService_InTeam_Call {
|
||||
return &MockTeamService_InTeam_Call{Call: _e.mock.On("InTeam", ctx, id, team, admin)}
|
||||
}
|
||||
|
||||
func (_c *MockTeamService_InTeam_Call) Run(run func(ctx context.Context, id types.AuthInfo, team string, admin bool)) *MockTeamService_InTeam_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(types.AuthInfo), args[2].(string), args[3].(bool))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTeamService_InTeam_Call) Return(_a0 bool, _a1 error) *MockTeamService_InTeam_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockTeamService_InTeam_Call) RunAndReturn(run func(context.Context, types.AuthInfo, string, bool) (bool, error)) *MockTeamService_InTeam_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockTeamService creates a new instance of MockTeamService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockTeamService(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockTeamService {
|
||||
mock := &MockTeamService{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -132,6 +132,8 @@ func (fm *FolderManager) EnsureFolderExists(ctx context.Context, folder Folder,
|
||||
|
||||
if parent != "" {
|
||||
meta.SetFolder(parent)
|
||||
} else {
|
||||
meta.SetAnnotation(utils.AnnoKeyGrantPermissions, utils.AnnoGrantPermissionsDefault)
|
||||
}
|
||||
meta.SetManagerProperties(utils.ManagerProperties{
|
||||
Kind: utils.ManagerKindRepo,
|
||||
|
||||
@@ -55,11 +55,11 @@ type fileChangeInfo struct {
|
||||
type evaluator struct {
|
||||
render ScreenshotRenderer
|
||||
parsers resources.ParserFactory
|
||||
urlProvider func(namespace string) string
|
||||
urlProvider func(ctx context.Context, namespace string) string
|
||||
metrics screenshotMetrics
|
||||
}
|
||||
|
||||
func NewEvaluator(render ScreenshotRenderer, parsers resources.ParserFactory, urlProvider func(namespace string) string, registry prometheus.Registerer) Evaluator {
|
||||
func NewEvaluator(render ScreenshotRenderer, parsers resources.ParserFactory, urlProvider func(ctx context.Context, namespace string) string, registry prometheus.Registerer) Evaluator {
|
||||
metrics := registerScreenshotMetrics(registry)
|
||||
return &evaluator{
|
||||
render: render,
|
||||
@@ -80,7 +80,7 @@ func (e *evaluator) Evaluate(ctx context.Context, repo repository.Reader, opts p
|
||||
rendererAvailable := e.render.IsAvailable(ctx)
|
||||
shouldRender := rendererAvailable && len(changes) == 1 && cfg.Spec.GitHub.GenerateDashboardPreviews
|
||||
info := changeInfo{
|
||||
GrafanaBaseURL: e.urlProvider(cfg.Namespace),
|
||||
GrafanaBaseURL: e.urlProvider(ctx, cfg.Namespace),
|
||||
MissingImageRenderer: !rendererAvailable,
|
||||
}
|
||||
|
||||
|
||||
@@ -754,7 +754,7 @@ func TestCalculateChanges(t *testing.T) {
|
||||
|
||||
tt.setupMocks(parser, reader, progress, renderer, parserFactory)
|
||||
|
||||
evaluator := NewEvaluator(renderer, parserFactory, func(_ string) string {
|
||||
evaluator := NewEvaluator(renderer, parserFactory, func(_ context.Context, _ string) string {
|
||||
if tt.grafanaBaseURL != "" {
|
||||
return tt.grafanaBaseURL
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func ProvidePullRequestWorker(
|
||||
configProvider apiserver.RestConfigProvider,
|
||||
registry prometheus.Registerer,
|
||||
) *PullRequestWorker {
|
||||
urlProvider := func(_ string) string {
|
||||
urlProvider := func(_ context.Context, _ string) string {
|
||||
return cfg.AppURL
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
type WebhookExtraBuilder struct {
|
||||
provisioningapis.ExtraBuilder
|
||||
isPublic bool
|
||||
urlProvider func(namespace string) string
|
||||
urlProvider func(ctx context.Context, namespace string) string
|
||||
}
|
||||
|
||||
// FIXME: separate the URL provider from connector to simplify operators
|
||||
@@ -38,7 +38,7 @@ func (b *WebhookExtraBuilder) WebhookURL(ctx context.Context, r *provisioning.Re
|
||||
gvr := provisioning.RepositoryResourceInfo.GroupVersionResource()
|
||||
webhookURL := fmt.Sprintf(
|
||||
"%sapis/%s/%s/namespaces/%s/%s/%s/webhook",
|
||||
b.urlProvider(r.GetNamespace()),
|
||||
b.urlProvider(ctx, r.GetNamespace()),
|
||||
gvr.Group,
|
||||
gvr.Version,
|
||||
r.GetNamespace(),
|
||||
@@ -66,10 +66,10 @@ func ProvideWebhooksWithImages(
|
||||
configProvider apiserver.RestConfigProvider,
|
||||
registry prometheus.Registerer,
|
||||
) *WebhookExtraBuilder {
|
||||
urlProvider := func(_ string) string {
|
||||
urlProvider := func(_ context.Context, _ string) string {
|
||||
return cfg.AppURL
|
||||
}
|
||||
isPublic := isPublicURL(urlProvider(""))
|
||||
isPublic := isPublicURL(urlProvider(context.Background(), ""))
|
||||
|
||||
return &WebhookExtraBuilder{
|
||||
isPublic: isPublic,
|
||||
@@ -102,11 +102,11 @@ func ProvideWebhooksWithImages(
|
||||
}
|
||||
|
||||
func ProvideWebhooks(provisioningURL string, registry prometheus.Registerer) *WebhookExtraBuilder {
|
||||
urlProvider := func(_ string) string {
|
||||
urlProvider := func(_ context.Context, _ string) string {
|
||||
return provisioningURL
|
||||
}
|
||||
|
||||
isPublic := isPublicURL(urlProvider(""))
|
||||
isPublic := isPublicURL(urlProvider(context.Background(), ""))
|
||||
|
||||
return &WebhookExtraBuilder{
|
||||
isPublic: isPublic,
|
||||
@@ -131,7 +131,7 @@ type WebhookExtraWithImages struct {
|
||||
func NewWebhookExtraWithImages(
|
||||
render *renderConnector,
|
||||
webhook *webhookConnector,
|
||||
urlProvider func(namespace string) string,
|
||||
urlProvider func(ctx context.Context, namespace string) string,
|
||||
workers []jobs.Worker,
|
||||
) *WebhookExtraWithImages {
|
||||
return &WebhookExtraWithImages{
|
||||
|
||||
@@ -34,17 +34,14 @@ func afterCreatePermissionCreator(ctx context.Context,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if val.GetAnnotation(utils.AnnoKeyManagerKind) != "" {
|
||||
return nil, fmt.Errorf("managed resource may not grant permissions")
|
||||
}
|
||||
auth, ok := authtypes.AuthInfoFrom(ctx)
|
||||
if !ok {
|
||||
return nil, errors.New("missing auth info")
|
||||
}
|
||||
|
||||
idtype := auth.GetIdentityType()
|
||||
if idtype != authtypes.TypeUser && idtype != authtypes.TypeServiceAccount {
|
||||
return nil, fmt.Errorf("only users or service accounts may grant themselves permissions using an annotation")
|
||||
if idtype != authtypes.TypeUser && idtype != authtypes.TypeServiceAccount && idtype != authtypes.TypeAccessPolicy {
|
||||
return nil, fmt.Errorf("only users, service accounts, and access policies may grant permissions using an annotation")
|
||||
}
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
authtypes "github.com/grafana/authlib/types"
|
||||
|
||||
@@ -40,20 +39,6 @@ func TestAfterCreatePermissionCreator(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "missing default permission creator")
|
||||
})
|
||||
|
||||
t.Run("should error for managed resources", func(t *testing.T) {
|
||||
obj := &v0alpha1.Dashboard{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
utils.AnnoKeyManagerKind: "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
creator, err := afterCreatePermissionCreator(context.Background(), nil, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, creator)
|
||||
require.Contains(t, err.Error(), "managed resource may not grant permissions")
|
||||
})
|
||||
|
||||
t.Run("should error when auth info is missing", func(t *testing.T) {
|
||||
obj := &v0alpha1.Dashboard{}
|
||||
creator, err := afterCreatePermissionCreator(context.Background(), nil, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
|
||||
@@ -108,6 +93,29 @@ func TestAfterCreatePermissionCreator(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should succeed for access policy identity", func(t *testing.T) {
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{
|
||||
Type: authtypes.TypeAccessPolicy,
|
||||
OrgID: 1,
|
||||
OrgRole: "Admin",
|
||||
UserID: 1,
|
||||
})
|
||||
obj := &v0alpha1.Dashboard{}
|
||||
key := &resourcepb.ResourceKey{
|
||||
Group: "test",
|
||||
Resource: "test",
|
||||
Namespace: "test",
|
||||
Name: "test",
|
||||
}
|
||||
|
||||
creator, err := afterCreatePermissionCreator(ctx, key, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, creator)
|
||||
|
||||
err = creator(ctx)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should error for non-user/non-service-account identity", func(t *testing.T) {
|
||||
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{
|
||||
Type: authtypes.TypeAnonymous,
|
||||
@@ -117,6 +125,6 @@ func TestAfterCreatePermissionCreator(t *testing.T) {
|
||||
creator, err := afterCreatePermissionCreator(ctx, nil, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, creator)
|
||||
require.Contains(t, err.Error(), "only users or service accounts may grant themselves permissions")
|
||||
require.Contains(t, err.Error(), "only users, service accounts, and access policies may grant permissions")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,36 +36,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/current": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Preferences"
|
||||
],
|
||||
"description": "Get preferences for requester. This combines the user preferences with the team and global defaults",
|
||||
"operationId": "currentPreferences",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "workspace",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "default"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/preferences": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -224,6 +194,36 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/preferences/merged": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Preferences"
|
||||
],
|
||||
"description": "Get preferences for requester. This combines the user preferences with the team and global defaults",
|
||||
"operationId": "mergedPreferences",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "workspace",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "default"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/preferences/{name}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1122,7 +1122,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}/write/{group}/{kind}/{id}": {
|
||||
"/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}/update/{group}/{kind}/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Stars"
|
||||
|
||||
@@ -127,15 +127,15 @@ func TestIntegrationPreferences(t *testing.T) {
|
||||
`"regionalFormat":""
|
||||
}`, string(jj))
|
||||
|
||||
current := apis.DoRequest(helper, apis.RequestParams{
|
||||
merged := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: clientAdmin.Args.User,
|
||||
Method: http.MethodGet,
|
||||
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/current",
|
||||
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged",
|
||||
}, &preferences.Preferences{})
|
||||
require.Equal(t, http.StatusOK, current.Response.StatusCode, "get current preferences")
|
||||
require.Equal(t, "saturday", *current.Result.Spec.WeekStart) // from user
|
||||
require.Equal(t, "africa", *current.Result.Spec.Timezone) // from team
|
||||
require.Equal(t, "dark", *current.Result.Spec.Theme) // from org
|
||||
require.Equal(t, "en-US", *current.Result.Spec.Language) // settings.ini
|
||||
require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences")
|
||||
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
|
||||
require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team
|
||||
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
|
||||
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
|
||||
dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/grafana/grafana/pkg/util/testutil"
|
||||
@@ -22,127 +24,160 @@ import (
|
||||
func TestIntegrationStars(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false, // required for experimental APIs
|
||||
DisableAnonymous: true,
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("legacy dashboard stars", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
starsClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: preferences.StarsResourceInfo.GroupVersionResource(),
|
||||
})
|
||||
dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(),
|
||||
for _, mode := range []grafanarest.DualWriterMode{
|
||||
grafanarest.Mode0,
|
||||
grafanarest.Mode2, // anything past 2 will fail
|
||||
} {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false, // required for experimental APIs
|
||||
DisableAnonymous: true,
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
},
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
"folders.folder.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
"stars.preferences.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
"preferences.preferences.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create 5 dashboards
|
||||
for i := range 5 {
|
||||
_, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": dashboardV1.DashboardResourceInfo.GroupVersion().String(),
|
||||
"kind": "Dashboard",
|
||||
t.Run(fmt.Sprintf("test stars (mode:%d)", mode), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
starsClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: preferences.StarsResourceInfo.GroupVersionResource(),
|
||||
})
|
||||
starsClientViewer := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Viewer,
|
||||
GVR: preferences.StarsResourceInfo.GroupVersionResource(),
|
||||
})
|
||||
dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(),
|
||||
})
|
||||
|
||||
// Create 5 dashboards
|
||||
for i := range 5 {
|
||||
_, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": dashboardV1.DashboardResourceInfo.GroupVersion().String(),
|
||||
"kind": "Dashboard",
|
||||
"metadata": map[string]any{
|
||||
"name": fmt.Sprintf("test-%d", i),
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": fmt.Sprintf("test %d", i),
|
||||
"schemaVersion": 42, // not really!
|
||||
"panels": []any{},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
found, err := dashboardClient.Resource.List(context.Background(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, found.Items, 5, "should be 5 dashboards")
|
||||
|
||||
// List is empty when we start
|
||||
rsp, err := starsClient.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, rsp.Items, "no stars saved yet")
|
||||
|
||||
raw := make(map[string]any)
|
||||
legacyResponse := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/user/stars/dashboard/uid/test-2",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
|
||||
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/user/stars/dashboard/uid/test-3",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
|
||||
|
||||
// List values and compare results
|
||||
rsp, err = starsClient.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
stars := typed(t, rsp, &preferences.StarsList{})
|
||||
|
||||
require.Len(t, stars.Items, 1, "user stars should exist")
|
||||
require.Equal(t, "user-"+starsClient.Args.User.Identity.GetIdentifier(),
|
||||
stars.Items[0].GetName(), "star resource for user")
|
||||
resources := stars.Items[0].Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.ElementsMatch(t, []string{"test-2", "test-3"}, resources[0].Names)
|
||||
|
||||
// Remove one star
|
||||
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/user/stars/dashboard/uid/test-3",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star")
|
||||
|
||||
rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
after := typed(t, rspObj, &preferences.Stars{})
|
||||
resources = after.Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.Equal(t, []string{"test-2"}, resources[0].Names)
|
||||
|
||||
// Change stars via k8s update
|
||||
rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]any{
|
||||
"name": fmt.Sprintf("test-%d", i),
|
||||
"name": "user-" + starsClient.Args.User.Identity.GetIdentifier(),
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": fmt.Sprintf("test %d", i),
|
||||
"schemaVersion": 42, // not really!
|
||||
"panels": []any{},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
found, err := dashboardClient.Resource.List(context.Background(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, found.Items, 5, "should be 5 dashboards")
|
||||
|
||||
// List is empty when we start
|
||||
rsp, err := starsClient.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, rsp.Items, "no stars saved yet")
|
||||
|
||||
raw := make(map[string]any)
|
||||
legacyResponse := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/user/stars/dashboard/uid/test-2",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
|
||||
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodPost,
|
||||
Path: "/api/user/stars/dashboard/uid/test-3",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "add dashboard star")
|
||||
|
||||
// List values and compare results
|
||||
rsp, err = starsClient.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
stars := typed(t, rsp, &preferences.StarsList{})
|
||||
|
||||
require.Len(t, stars.Items, 1, "user stars should exist")
|
||||
require.Equal(t, "user-"+starsClient.Args.User.Identity.GetIdentifier(),
|
||||
stars.Items[0].GetName(), "star resource for user")
|
||||
resources := stars.Items[0].Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.ElementsMatch(t, []string{"test-2", "test-3"}, resources[0].Names)
|
||||
|
||||
// Remove one star
|
||||
legacyResponse = apis.DoRequest(helper, apis.RequestParams{
|
||||
User: starsClient.Args.User,
|
||||
Method: http.MethodDelete,
|
||||
Path: "/api/user/stars/dashboard/uid/test-3",
|
||||
}, &raw)
|
||||
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star")
|
||||
|
||||
rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
after := typed(t, rspObj, &preferences.Stars{})
|
||||
resources = after.Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.Equal(t, []string{"test-2"}, resources[0].Names)
|
||||
|
||||
// Change stars via k8s update
|
||||
rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]any{
|
||||
"name": "user-" + starsClient.Args.User.Identity.GetIdentifier(),
|
||||
"namespace": "default",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"resource": []map[string]any{
|
||||
{
|
||||
"group": "dashboard.grafana.app",
|
||||
"kind": "Dashboard",
|
||||
"names": []string{"test-2", "aaa", "bbb"},
|
||||
"resource": []map[string]any{
|
||||
{
|
||||
"group": "dashboard.grafana.app",
|
||||
"kind": "Dashboard",
|
||||
"names": []string{"test-2", "aaa", "bbb"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
}, metav1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
after = typed(t, rspObj, &preferences.Stars{})
|
||||
resources = after.Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.ElementsMatch(t,
|
||||
[]string{"test-2", "aaa", "bbb"}, // NOTE 2 stays, 3 removed, added aaa+bbb
|
||||
resources[0].Names)
|
||||
})
|
||||
after = typed(t, rspObj, &preferences.Stars{})
|
||||
resources = after.Spec.Resource
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, "dashboard.grafana.app", resources[0].Group)
|
||||
require.Equal(t, "Dashboard", resources[0].Kind)
|
||||
require.ElementsMatch(t,
|
||||
[]string{"test-2", "aaa", "bbb"}, // NOTE 2 stays, 3 removed, added aaa+bbb
|
||||
resources[0].Names)
|
||||
|
||||
// Viewer does not have any stars
|
||||
rsp, err = starsClientViewer.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, rsp.Items, "expect empty list")
|
||||
|
||||
// Not allowed to see another user's stars
|
||||
rspObj, err = starsClientViewer.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, rspObj)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func typed[T any](t *testing.T, obj any, out T) T {
|
||||
|
||||
@@ -10,10 +10,6 @@ const injectedRtkApi = api
|
||||
query: () => ({ url: `/apis/preferences.grafana.app/v1alpha1/` }),
|
||||
providesTags: ['API Discovery'],
|
||||
}),
|
||||
currentPreferences: build.query<CurrentPreferencesApiResponse, CurrentPreferencesApiArg>({
|
||||
query: () => ({ url: `/current` }),
|
||||
providesTags: ['Preferences'],
|
||||
}),
|
||||
listPreferences: build.query<ListPreferencesApiResponse, ListPreferencesApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/preferences`,
|
||||
@@ -33,6 +29,10 @@ const injectedRtkApi = api
|
||||
}),
|
||||
providesTags: ['Preferences'],
|
||||
}),
|
||||
mergedPreferences: build.query<MergedPreferencesApiResponse, MergedPreferencesApiArg>({
|
||||
query: () => ({ url: `/preferences/merged` }),
|
||||
providesTags: ['Preferences'],
|
||||
}),
|
||||
getPreferences: build.query<GetPreferencesApiResponse, GetPreferencesApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/preferences/${queryArg.name}`,
|
||||
@@ -153,14 +153,14 @@ const injectedRtkApi = api
|
||||
}),
|
||||
addStar: build.mutation<AddStarApiResponse, AddStarApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/stars/${queryArg.name}/write/${queryArg.group}/${queryArg.kind}/${queryArg.id}`,
|
||||
url: `/stars/${queryArg.name}/update/${queryArg.group}/${queryArg.kind}/${queryArg.id}`,
|
||||
method: 'PUT',
|
||||
}),
|
||||
invalidatesTags: ['Stars'],
|
||||
}),
|
||||
removeStar: build.mutation<RemoveStarApiResponse, RemoveStarApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/stars/${queryArg.name}/write/${queryArg.group}/${queryArg.kind}/${queryArg.id}`,
|
||||
url: `/stars/${queryArg.name}/update/${queryArg.group}/${queryArg.kind}/${queryArg.id}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: ['Stars'],
|
||||
@@ -171,8 +171,6 @@ const injectedRtkApi = api
|
||||
export { injectedRtkApi as generatedAPI };
|
||||
export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList;
|
||||
export type GetApiResourcesApiArg = void;
|
||||
export type CurrentPreferencesApiResponse = /** status 200 undefined */ any;
|
||||
export type CurrentPreferencesApiArg = void;
|
||||
export type ListPreferencesApiResponse = /** status 200 OK */ PreferencesList;
|
||||
export type ListPreferencesApiArg = {
|
||||
/** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */
|
||||
@@ -218,6 +216,8 @@ export type ListPreferencesApiArg = {
|
||||
/** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */
|
||||
watch?: boolean;
|
||||
};
|
||||
export type MergedPreferencesApiResponse = /** status 200 undefined */ any;
|
||||
export type MergedPreferencesApiArg = void;
|
||||
export type GetPreferencesApiResponse = /** status 200 OK */ Preferences;
|
||||
export type GetPreferencesApiArg = {
|
||||
/** name of the Preferences */
|
||||
@@ -704,8 +704,8 @@ export type Status = {
|
||||
export type Patch = object;
|
||||
export const {
|
||||
useGetApiResourcesQuery,
|
||||
useCurrentPreferencesQuery,
|
||||
useListPreferencesQuery,
|
||||
useMergedPreferencesQuery,
|
||||
useGetPreferencesQuery,
|
||||
useListStarsQuery,
|
||||
useCreateStarsMutation,
|
||||
|
||||
@@ -2143,10 +2143,10 @@
|
||||
"suffix": "Aktualizujte stránku a zkuste to znovu.",
|
||||
"title": "Nepodařilo se přidat nebo aktualizovat zásadu oznamování"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} další zásady",
|
||||
"n-more-policies_few": "{{count}} další zásady",
|
||||
"n-more-policies_many": "{{count}} další zásady",
|
||||
"n-more-policies_other": "{{count}} další zásady"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_few": "",
|
||||
"n-more-policies_many": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nová podřízená zásada",
|
||||
@@ -12053,14 +12053,14 @@
|
||||
"restore-button": "Obnovit",
|
||||
"restore-loading": "Probíhá obnovení…",
|
||||
"title": "Obnovit nástěnky",
|
||||
"folder-picker-text_one": "Vyberte složku, do které budou vaše nástěnky obnoveny.",
|
||||
"folder-picker-text_few": "Vyberte složku, do které budou vaše nástěnky obnoveny.",
|
||||
"folder-picker-text_many": "Vyberte složku, do které budou vaše nástěnky obnoveny.",
|
||||
"folder-picker-text_other": "Vyberte složku, do které budou vaše nástěnky obnoveny.",
|
||||
"text_one": "Tato akce obnoví následující počet nástěnek: {{numberOfDashboards}}.",
|
||||
"text_few": "Tato akce obnoví následující počet nástěnek: {{numberOfDashboards}}.",
|
||||
"text_many": "Tato akce obnoví následující počet nástěnek: {{numberOfDashboards}}.",
|
||||
"text_other": "Tato akce obnoví následující počet nástěnek: {{numberOfDashboards}}."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_few": "",
|
||||
"folder-picker-text_many": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_few": "",
|
||||
"text_many": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Bitte aktualisieren Sie die Seite und versuchen Sie es erneut.",
|
||||
"title": "Benachrichtigungsrichtlinie konnte nicht hinzugefügt oder aktualisiert werden"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} zusätzliche Richtlinie",
|
||||
"n-more-policies_other": "{{count}} zusätzliche Richtlinien"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Neue untergeordnete Richtlinie",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Wiederherstellen",
|
||||
"restore-loading": "Wird wiederhergestellt …",
|
||||
"title": "Dashboards wiederherstellen",
|
||||
"folder-picker-text_one": "Bitte wählen Sie einen Ordner aus, in dem Ihr Dashboard wiederhergestellt werden soll.",
|
||||
"folder-picker-text_other": "Bitte wählen Sie einen Ordner aus, in dem Ihre Dashboards wiederhergestellt werden sollen.",
|
||||
"text_one": "Mit dieser Aktion wird {{numberOfDashboards}} Dashboard wiederhergestellt.",
|
||||
"text_other": "Mit dieser Aktion werden {{numberOfDashboards}} Dashboards wiederhergestellt."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Actualiza la página y vuelve a intentarlo.",
|
||||
"title": "Error al añadir o actualizar la política de notificación"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} políticas adicionales",
|
||||
"n-more-policies_other": "{{count}} políticas adicionales"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nueva política secundaria",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Restaurar",
|
||||
"restore-loading": "Restaurando...",
|
||||
"title": "Restaurar paneles de control",
|
||||
"folder-picker-text_one": "Elige la carpeta en la que se restaurarán tus paneles de control.",
|
||||
"folder-picker-text_other": "Elige la carpeta en la que se restaurarán tus paneles de control.",
|
||||
"text_one": "Esta acción restaurará {{numberOfDashboards}} paneles de control.",
|
||||
"text_other": "Esta acción restaurará {{numberOfDashboards}} paneles de control."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Actualisez la page, puis réessayez.",
|
||||
"title": "Échec lors de l’ajout ou de la mise à jour de la politique de notification"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} politiques supplémentaires",
|
||||
"n-more-policies_other": "{{count}} politiques supplémentaires"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nouvelle politique enfant",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Restaurer",
|
||||
"restore-loading": "Restauration...",
|
||||
"title": "Restaurer les tableaux de bord",
|
||||
"folder-picker-text_one": "Veuillez choisir un dossier dans lequel vos tableaux de bord seront restaurés.",
|
||||
"folder-picker-text_other": "Veuillez choisir un dossier dans lequel vos tableaux de bord seront restaurés.",
|
||||
"text_one": "Cette action restaurera {{numberOfDashboards}} tableaux de bord.",
|
||||
"text_other": "Cette action restaurera {{numberOfDashboards}} tableaux de bord."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Kérjük, frissítse az oldalt, és próbálkozzon újra.",
|
||||
"title": "Nem sikerült értesítési szabályzatot hozzáadni vagy frissíteni"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} további házirend",
|
||||
"n-more-policies_other": "{{count}} további házirend"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Új gyermekházirend",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Visszaállítás",
|
||||
"restore-loading": "Visszaállítás...",
|
||||
"title": "Irányítópultok visszaállítása",
|
||||
"folder-picker-text_one": "Kérjük, válasszon egy mappát, ahová az irányítópultok vissza lesznek állítva.",
|
||||
"folder-picker-text_other": "Kérjük, válasszon egy mappát, ahová az irányítópultok vissza lesznek állítva.",
|
||||
"text_one": "Ez a művelet visszaállít {{numberOfDashboards}} irányítópultot.",
|
||||
"text_other": "Ez a művelet visszaállít {{numberOfDashboards}} irányítópultot."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Aggiorna la pagina e riprova.",
|
||||
"title": "Impossibile aggiungere o aggiornare i criteri di notifica"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} criterio aggiuntivo",
|
||||
"n-more-policies_other": "{{count}} criteri aggiuntivi"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nuova politica per i bambini",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Ripristina",
|
||||
"restore-loading": "Ripristino in corso...",
|
||||
"title": "Ripristina dashboard",
|
||||
"folder-picker-text_one": "Scegli una cartella in cui verrà ripristinato il dashboard.",
|
||||
"folder-picker-text_other": "Scegli una cartella in cui verranno ripristinati i dashboard.",
|
||||
"text_one": "Questa azione ripristinerà {{numberOfDashboards}} dashboard.",
|
||||
"text_other": "Questa azione ripristinerà {{numberOfDashboards}} dashboard."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Vernieuw de pagina en probeer het opnieuw.",
|
||||
"title": "Kan meldingsbeleid niet toevoegen of bijwerken"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} aanvullend beleid",
|
||||
"n-more-policies_other": "{{count}} aanvullend beleid"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nieuw onderliggend beleid",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Herstellen",
|
||||
"restore-loading": "Bezig met herstellen ...",
|
||||
"title": "Dashboards herstellen",
|
||||
"folder-picker-text_one": "Kies een map waarin je dashboards worden hersteld.",
|
||||
"folder-picker-text_other": "Kies een map waarin je dashboards worden hersteld.",
|
||||
"text_one": "Met deze actie worden {{numberOfDashboards}} dashboards hersteld.",
|
||||
"text_other": "Met deze actie worden {{numberOfDashboards}} dashboards hersteld."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2143,10 +2143,10 @@
|
||||
"suffix": "Odśwież stronę i spróbuj ponownie.",
|
||||
"title": "Nie udało się dodać lub zaktualizować zasad dotyczących powiadomień"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} dodatkowy zbiór zasad",
|
||||
"n-more-policies_few": "{{count}} dodatkowe zbiory zasad",
|
||||
"n-more-policies_many": "{{count}} dodatkowych zbiorów zasad",
|
||||
"n-more-policies_other": "{{count}} dodatkowego zbioru zasad"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_few": "",
|
||||
"n-more-policies_many": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nowe zasady pochodne",
|
||||
@@ -12053,14 +12053,14 @@
|
||||
"restore-button": "Przywróć",
|
||||
"restore-loading": "Przywracanie…",
|
||||
"title": "Przywróć pulpity",
|
||||
"folder-picker-text_one": "Wybierz folder, w którym zostanie przywrócony pulpit.",
|
||||
"folder-picker-text_few": "Wybierz folder, w którym zostaną przywrócone pulpity.",
|
||||
"folder-picker-text_many": "Wybierz folder, w którym zostaną przywrócone pulpity.",
|
||||
"folder-picker-text_other": "Wybierz folder, w którym zostaną przywrócone pulpity.",
|
||||
"text_one": "To działanie spowoduje przywrócenie {{numberOfDashboards}} pulpitu.",
|
||||
"text_few": "To działanie spowoduje przywrócenie {{numberOfDashboards}} pulpitów.",
|
||||
"text_many": "To działanie spowoduje przywrócenie {{numberOfDashboards}} pulpitów.",
|
||||
"text_other": "To działanie spowoduje przywrócenie {{numberOfDashboards}} pulpitu."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_few": "",
|
||||
"folder-picker-text_many": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_few": "",
|
||||
"text_many": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Atualize a página e tente novamente.",
|
||||
"title": "Falha ao adicionar ou atualizar a política de notificação"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} política adicional",
|
||||
"n-more-policies_other": "{{count}} políticas adicionais"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nova política secundária",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Restaurar",
|
||||
"restore-loading": "Restaurando…",
|
||||
"title": "Restaurar painéis de controle",
|
||||
"folder-picker-text_one": "Escolha uma pasta para onde seu painel de controle será restaurado.",
|
||||
"folder-picker-text_other": "Escolha uma pasta para onde seus painéis de controle serão restaurados.",
|
||||
"text_one": "Esta ação restaurará {{numberOfDashboards}} painel de controle.",
|
||||
"text_other": "Esta ação restaurará {{numberOfDashboards}} painéis de controle."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Por favor, atualize a página e tente novamente.",
|
||||
"title": "Falha ao adicionar ou atualizar a política de notificação"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} políticas adicionais",
|
||||
"n-more-policies_other": "{{count}} políticas adicionais"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Nova política secundária",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Restaurar",
|
||||
"restore-loading": "A restaurar...",
|
||||
"title": "Restaurar painéis de controlo",
|
||||
"folder-picker-text_one": "Escolha uma pasta onde os seus painéis de controlo serão restaurados.",
|
||||
"folder-picker-text_other": "Escolha uma pasta onde os seus painéis de controlo serão restaurados.",
|
||||
"text_one": "Esta ação irá restaurar {{numberOfDashboards}} painéis de controlo.",
|
||||
"text_other": "Esta ação irá restaurar {{numberOfDashboards}} painéis de controlo."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2143,10 +2143,10 @@
|
||||
"suffix": "Обновите страницу и повторите попытку.",
|
||||
"title": "Не удалось добавить или обновить политику уведомления"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} дополнительная политика",
|
||||
"n-more-policies_few": "{{count}} дополнительные политики",
|
||||
"n-more-policies_many": "{{count}} дополнительных политик",
|
||||
"n-more-policies_other": "{{count}} дополнительной политики"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_few": "",
|
||||
"n-more-policies_many": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Новая дочерняя политика",
|
||||
@@ -12053,14 +12053,14 @@
|
||||
"restore-button": "Восстановить",
|
||||
"restore-loading": "Восстановление...",
|
||||
"title": "Восстановление дашбордов",
|
||||
"folder-picker-text_one": "Выберите папку для восстановления вашего дашборда.",
|
||||
"folder-picker-text_few": "Выберите папку для восстановления ваших дашбордов.",
|
||||
"folder-picker-text_many": "Выберите папку для восстановления ваших дашбордов.",
|
||||
"folder-picker-text_other": "Выберите папку для восстановления вашего дашборда.",
|
||||
"text_one": "Это действие приведет к восстановлению {{numberOfDashboards}} дашборда.",
|
||||
"text_few": "Это действие приведет к восстановлению {{numberOfDashboards}} дашбордов.",
|
||||
"text_many": "Это действие приведет к восстановлению {{numberOfDashboards}} дашбордов.",
|
||||
"text_other": "Это действие приведет к восстановлению {{numberOfDashboards}} дашборда."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_few": "",
|
||||
"folder-picker-text_many": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_few": "",
|
||||
"text_many": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Uppdatera sidan och försök igen.",
|
||||
"title": "Det gick inte att lägga till eller uppdatera aviseringspolicy"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} ytterligare policyer",
|
||||
"n-more-policies_other": "{{count}} ytterligare policyer"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Ny underordnad policy",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Återställ",
|
||||
"restore-loading": "Återställer ...",
|
||||
"title": "Återställ instrumentpaneler",
|
||||
"folder-picker-text_one": "Välj en mapp där dina instrumentpaneler kommer att återställas.",
|
||||
"folder-picker-text_other": "Välj en mapp där dina instrumentpaneler kommer att återställas.",
|
||||
"text_one": "Denna åtgärd återställer {{numberOfDashboards}} instrumentpanel.",
|
||||
"text_other": "Denna åtgärd återställer {{numberOfDashboards}} instrumentpanel."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
@@ -2129,8 +2129,8 @@
|
||||
"suffix": "Lütfen sayfayı yenileyin ve tekrar deneyin.",
|
||||
"title": "Bildirim politikası eklenemedi veya güncellenemedi"
|
||||
},
|
||||
"n-more-policies_one": "{{count}} ek politika",
|
||||
"n-more-policies_other": "{{count}} ek politika"
|
||||
"n-more-policies_one": "",
|
||||
"n-more-policies_other": ""
|
||||
},
|
||||
"policy": {
|
||||
"label-new-child-policy": "Çocuklara ilişkin yeni politika",
|
||||
@@ -11971,10 +11971,10 @@
|
||||
"restore-button": "Geri yükle",
|
||||
"restore-loading": "Geri yükleniyor…",
|
||||
"title": "Panoları Geri Yükleme",
|
||||
"folder-picker-text_one": "Lütfen panolarınızın geri yükleneceği bir klasör seçin.",
|
||||
"folder-picker-text_other": "Lütfen panolarınızın geri yükleneceği bir klasör seçin.",
|
||||
"text_one": "Bu işlem {{numberOfDashboards}} panoyu geri yükleyecektir.",
|
||||
"text_other": "Bu işlem {{numberOfDashboards}} panoyu geri yükleyecektir."
|
||||
"folder-picker-text_one": "",
|
||||
"folder-picker-text_other": "",
|
||||
"text_one": "",
|
||||
"text_other": ""
|
||||
}
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
|
||||
Reference in New Issue
Block a user