From 29939bcd797a2ac159eac36f1c2eb38833db6f59 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 11 May 2026 18:58:44 +0300 Subject: [PATCH] DataSources: Load secure values from decrypter (#124515) --- apps/advisor/go.mod | 2 +- apps/dashvalidator/go.mod | 1 + pkg/api/pluginproxy/pluginproxy.go | 4 +- pkg/extensions/enterprise_imports.go | 1 + pkg/registry/apis/appplugin/context.go | 64 +--- pkg/registry/apis/appplugin/settings.go | 10 +- pkg/registry/apis/appplugin/sub_proxy.go | 2 +- pkg/registry/apis/datasource/plugincontext.go | 6 + pkg/registry/apis/datasource/register.go | 34 +- pkg/registry/apis/datasource/settings.go | 72 ++++ pkg/registry/apis/datasource/sub_access.go | 3 +- pkg/server/wire_gen.go | 56 ++-- .../pluginsettings/decrypted.go | 98 ++++++ .../pluginsettings/decrypted_test.go | 313 ++++++++++++++++++ .../pluginsettings/models.go | 4 - 15 files changed, 564 insertions(+), 106 deletions(-) create mode 100644 pkg/registry/apis/datasource/settings.go create mode 100644 pkg/services/pluginsintegration/pluginsettings/decrypted.go create mode 100644 pkg/services/pluginsintegration/pluginsettings/decrypted_test.go diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 01ffdb72f11..941ee18de81 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -159,7 +159,6 @@ require ( github.com/go-openapi/validate v0.25.2 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-stack/stack v1.8.1 // indirect - github.com/go-test/deep v1.1.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect @@ -183,6 +182,7 @@ require ( github.com/grafana/grafana-aws-sdk v1.4.4 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.4.0 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect + github.com/grafana/grafana/apps/secret v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/grafana/pkg/infra/features v0.0.0 // indirect github.com/grafana/grafana/pkg/semconv v0.0.0 // indirect diff --git a/apps/dashvalidator/go.mod b/apps/dashvalidator/go.mod index c3cec19c1aa..b1dbdfbce5e 100644 --- a/apps/dashvalidator/go.mod +++ b/apps/dashvalidator/go.mod @@ -137,6 +137,7 @@ require ( github.com/grafana/grafana/apps/folder v0.0.0 // indirect github.com/grafana/grafana/apps/iam v0.0.0 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect + github.com/grafana/grafana/apps/secret v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/grafana/pkg/infra/features v0.0.0 // indirect github.com/grafana/grafana/pkg/plugins v0.0.0 // indirect diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index 8cf40c9ab13..7b3a80569b4 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -33,7 +33,7 @@ type PluginProxy struct { matchedRoute *plugins.Route dataProxyLogging bool // from cfg sendUserHeader bool // from cfg - secureJsonData pluginsettings.SecureJsonGetter + secureJsonData pluginsettings.DecryptedSecureJSONLoader tracer tracing.Tracer transport *http.Transport features featuremgmt.FeatureToggles @@ -44,7 +44,7 @@ func NewPluginProxy(ps *pluginsettings.DTO, routes []*plugins.Route, r *http.Request, w http.ResponseWriter, signedInUser identity.Requester, proxyPath string, dataProxyLogging bool, sendUserHeader bool, - secureJsonData pluginsettings.SecureJsonGetter, tracer tracing.Tracer, + secureJsonData pluginsettings.DecryptedSecureJSONLoader, tracer tracing.Tracer, transport *http.Transport, accessControl ac.AccessControl, features featuremgmt.FeatureToggles) (*PluginProxy, error) { return &PluginProxy{ accessControl: accessControl, diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index b340fd52c60..c2f866401de 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -36,6 +36,7 @@ import ( _ "github.com/dlmiddlecote/sqlstats" _ "github.com/dolthub/go-mysql-server" _ "github.com/dolthub/go-mysql-server/sql" + _ "github.com/dolthub/go-mysql-server/sql/analyzer" _ "github.com/dolthub/go-mysql-server/sql/expression" _ "github.com/dolthub/go-mysql-server/sql/expression/function/aggregation" _ "github.com/dolthub/go-mysql-server/sql/plan" diff --git a/pkg/registry/apis/appplugin/context.go b/pkg/registry/apis/appplugin/context.go index 3679d6c12c1..8147666f182 100644 --- a/pkg/registry/apis/appplugin/context.go +++ b/pkg/registry/apis/appplugin/context.go @@ -9,31 +9,13 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/apimachinery/utils" apppluginV0 "github.com/grafana/grafana/pkg/apis/appplugin/v0alpha1" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" ) -type ctxAppDTO struct{} - -type shimDTO struct { - getDecryptedSecureJSONData pluginsettings.SecureJsonGetter -} - -// This can be removed when we no longer support loading directly from the legacy SQL store -func withShimDTO(ctx context.Context) context.Context { - return context.WithValue(ctx, ctxAppDTO{}, &shimDTO{}) -} - -func legacyShimFromContext(ctx context.Context) *shimDTO { - shim, ok := ctx.Value(ctxAppDTO{}).(*shimDTO) - if !ok { - return nil - } - return shim -} - -func (b *AppPluginAPIBuilder) getSettings(ctx context.Context) (*apppluginV0.Settings, pluginsettings.SecureJsonGetter, error) { - ctx = withShimDTO(ctx) +func (b *AppPluginAPIBuilder) getSettings(ctx context.Context) (*apppluginV0.Settings, pluginsettings.DecryptedSecureJSONLoader, error) { + ctx = pluginsettings.WithSecureContextShim(ctx) raw, err := b.getter.Get(ctx, apppluginV0.INSTANCE_NAME, &v1.GetOptions{}) if err != nil { return nil, nil, err @@ -47,44 +29,16 @@ func (b *AppPluginAPIBuilder) getSettings(ctx context.Context) (*apppluginV0.Set } if len(settings.Secure) < 1 { - return settings, func(ctx context.Context) (map[string]string, error) { return map[string]string{}, nil }, nil + return settings, pluginsettings.EmptyDecryptedSecureJSONLoader, nil } - shim := legacyShimFromContext(ctx) - if shim != nil && shim.getDecryptedSecureJSONData != nil { - return settings, shim.getDecryptedSecureJSONData, nil + obj, err := utils.MetaAccessor(settings) + if err != nil { + return nil, nil, err } - // Returns settings and a function to get decrypted secure values - return settings, func(ctx context.Context) (map[string]string, error) { - names := make([]string, 0, len(settings.Secure)) - for k, v := range settings.Secure { - if v.Name == "" { - return nil, fmt.Errorf("missing secure value name for key: %s", k) - } - names = append(names, v.Name) - } - lookup, err := b.decrypter.Decrypt(ctx, b.groupVersion.Group, settings.Namespace, names...) - if err != nil { - return nil, fmt.Errorf("error decrypting secure values: %w", err) - } - - decrypted := make(map[string]string) - for k, sv := range settings.Secure { - v, ok := lookup[sv.Name] - if !ok { - return nil, fmt.Errorf("unable to find secure value: %s for key: %s", sv.Name, k) - } - if v.Error() != nil { - return nil, fmt.Errorf("error decrypting secure value: %s / %w", k, v.Error()) - } - val := v.Value() - if val != nil { - decrypted[k] = val.DangerouslyExposeAndConsumeValue() - } - } - return decrypted, nil - }, nil + loader, err := pluginsettings.GetDecryptedSecureJSONLoader(ctx, obj, b.decrypter) + return settings, loader, err } // Gets plugin context with decrypted secure values diff --git a/pkg/registry/apis/appplugin/settings.go b/pkg/registry/apis/appplugin/settings.go index 65aec47bfc7..9532b8f48eb 100644 --- a/pkg/registry/apis/appplugin/settings.go +++ b/pkg/registry/apis/appplugin/settings.go @@ -151,13 +151,9 @@ func (s *settingsStorage) get(ctx context.Context) (*apppluginV0.Settings, error return nil, fmt.Errorf("failed to get plugin settings: %w", err) } if ps != nil { - shim := legacyShimFromContext(ctx) - if shim != nil { - shim.getDecryptedSecureJSONData = func(ctx context.Context) (map[string]string, error) { - v := s.pluginSettings.DecryptedValues(ps) - return v, nil // odd this does not have an error - } - } + pluginsettings.WithDecryptedValues(ctx, func(ctx context.Context) (map[string]string, error) { + return s.pluginSettings.DecryptedValues(ps), nil + }) obj.SetCreationTimestamp(metav1.NewTime(ps.Updated)) obj.SetResourceVersion(getLegacySettingsResourceVersion(ps)) diff --git a/pkg/registry/apis/appplugin/sub_proxy.go b/pkg/registry/apis/appplugin/sub_proxy.go index d23b3928ba9..501415cc084 100644 --- a/pkg/registry/apis/appplugin/sub_proxy.go +++ b/pkg/registry/apis/appplugin/sub_proxy.go @@ -30,7 +30,7 @@ import ( type subProxyREST struct { pluginID string routes []*plugins.Route - settingsProvider func(ctx context.Context) (*apppluginV0.Settings, pluginsettings.SecureJsonGetter, error) + settingsProvider func(ctx context.Context) (*apppluginV0.Settings, pluginsettings.DecryptedSecureJSONLoader, error) accessControl ac.AccessControl tracer tracing.Tracer features featuremgmt.FeatureToggles diff --git a/pkg/registry/apis/datasource/plugincontext.go b/pkg/registry/apis/datasource/plugincontext.go index 32130ee246a..31523893e5c 100644 --- a/pkg/registry/apis/datasource/plugincontext.go +++ b/pkg/registry/apis/datasource/plugincontext.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/setting" ) @@ -178,6 +179,11 @@ func (q *scopedDatasourceProvider) GetDataSource(ctx context.Context, uid string } } + // Add the decrypted secrets to the context if they were requested + pluginsettings.WithDecryptedValues(ctx, func(ctx context.Context) (map[string]string, error) { + return secrets, nil + }) + return q.converter.AsDataSource(ds) } diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index 32091da8f0d..1902bf22496 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/attribute" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -15,13 +16,14 @@ import ( genericapiserver "k8s.io/apiserver/pkg/server" openapi "k8s.io/kube-openapi/pkg/common" - "go.opentelemetry.io/otel/attribute" - + authlib "github.com/grafana/authlib/types" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/experimental/pluginschema" + "github.com/grafana/grafana/apps/secret/pkg/decrypt" "github.com/grafana/grafana/pkg/apimachinery/utils" datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/tracing" @@ -56,11 +58,16 @@ type DataSourceAPIBuilder struct { client PluginClient // will only ever be called with the same plugin id! datasources PluginDatasourceProvider contextProvider PluginContextWrapper - accessControl accesscontrol.AccessControl + decrypter decrypt.DecryptService // when not reading legacy + accessControl accesscontrol.AccessControl // ST Only + accessClient authlib.AccessClient // MT+ST schemas map[string]*pluginschema.PluginSchema queryTypes *datasourceV0.QueryTypeDefinitionList cfg DataSourceAPIBuilderConfig dataSourceCRUDMetric *prometheus.HistogramVec + + // Legacy or Unified -- depending on config + store grafanarest.Storage } func RegisterAPIService( @@ -69,7 +76,9 @@ func RegisterAPIService( pluginClient plugins.Client, // access to everything datasources ScopedPluginDatasourceProvider, contextProvider PluginContextWrapper, + decrypter decrypt.DecryptService, // when not reading legacy accessControl accesscontrol.AccessControl, + accessClient authlib.AccessClient, reg prometheus.Registerer, pluginSources sources.Registry, ) (*DataSourceAPIBuilder, error) { @@ -124,6 +133,7 @@ func RegisterAPIService( datasources.GetDatasourceProvider(plugin.JSONData), contextProvider, accessControl, + decrypter, flags, ) if err != nil { @@ -136,6 +146,7 @@ func RegisterAPIService( if plugin.Schemas != nil { builder.schemas = plugin.Schemas } + builder.accessClient = accessClient // Only registered in ST for now apiRegistrar.RegisterAPI(builder) } @@ -159,6 +170,7 @@ func NewDataSourceAPIBuilder( datasources PluginDatasourceProvider, contextProvider PluginContextWrapper, accessControl accesscontrol.AccessControl, + decrypter decrypt.DecryptService, // when not reading legacy cfg DataSourceAPIBuilderConfig, ) (*DataSourceAPIBuilder, error) { registerSubresourceMetrics(prometheus.DefaultRegisterer) @@ -170,6 +182,7 @@ func NewDataSourceAPIBuilder( datasources: datasources, contextProvider: contextProvider, accessControl: accessControl, + decrypter: decrypter, cfg: cfg, } return builder, nil @@ -263,15 +276,16 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver if err != nil { return err } - storage[ds.StoragePath()], err = opts.DualWriteBuilder(ds.GroupResource(), legacyStore, unified) + b.store, err = opts.DualWriteBuilder(ds.GroupResource(), legacyStore, unified) if err != nil { return err } + storage[ds.StoragePath()] = b.store storage[ds.StoragePath("access")] = &subAccessREST{ builder: b, - getter: legacyStore, } } else { + // Read only datasources storage[ds.StoragePath()] = &connectionAccess{ datasources: b.datasources, resourceInfo: ds, @@ -348,7 +362,15 @@ func (b *DataSourceAPIBuilder) getPluginContext(ctx context.Context, uid string) defer span.End() getInstanceCtx, getInstanceSpan := tracing.Start(ctx, "datasource.getPluginContext.getInstanceSettings") - instance, err := b.datasources.GetInstanceSettings(getInstanceCtx, uid) + var err error + var instance *backend.DataSourceInstanceSettings + if b.store != nil && b.decrypter != nil { + // Load from storage + decrypter (respecting dual write settings) + instance, err = b.getInstanceSettings(getInstanceCtx, uid) + } else { + // This is backed by the datasources abstraction, NOT storage + instance, err = b.datasources.GetInstanceSettings(getInstanceCtx, uid) + } getInstanceSpan.End() if err != nil { err = tracing.Error(span, err) diff --git a/pkg/registry/apis/datasource/settings.go b/pkg/registry/apis/datasource/settings.go new file mode 100644 index 00000000000..a60843365b2 --- /dev/null +++ b/pkg/registry/apis/datasource/settings.go @@ -0,0 +1,72 @@ +package datasource + +import ( + "context" + "encoding/json" + "fmt" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/apimachinery/utils" + datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" +) + +func (b *DataSourceAPIBuilder) getInstanceSettings(ctx context.Context, name string) (*backend.DataSourceInstanceSettings, error) { + ctx = pluginsettings.WithSecureContextShim(ctx) + raw, err := b.store.Get(ctx, name, &v1.GetOptions{}) + if err != nil { + return nil, err + } + ds, ok := raw.(*datasourceV0.DataSource) + if !ok { + return nil, fmt.Errorf("unexpected type %T when getting plugin settings", raw) + } + + obj, err := utils.MetaAccessor(ds) + if err != nil { + return nil, err + } + + ts, _ := obj.GetUpdatedTimestamp() + if ts == nil { + ts = ptr.To(obj.GetCreationTimestamp().Time) + } + + gvk := obj.GetGroupVersionKind() + if gvk.Version == "" { + gvk.Version = datasourceV0.VERSION + } + + settings := &backend.DataSourceInstanceSettings{ + UID: ds.Name, + Type: b.pluginJSON.ID, + URL: ds.Spec.URL(), + ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck + Name: ds.Spec.Title(), + User: ds.Spec.User(), + Database: ds.Spec.Database(), + BasicAuthEnabled: ds.Spec.BasicAuth(), + BasicAuthUser: ds.Spec.BasicAuthUser(), + Updated: *ts, + APIVersion: gvk.Version, + } + + settings.JSONData, err = json.Marshal(ds.Spec.JSONData()) + if err != nil { + return nil, err + } + + if len(ds.Secure) < 1 { + return settings, nil + } + + loader, err := pluginsettings.GetDecryptedSecureJSONLoader(ctx, obj, b.decrypter) + if err != nil { + return nil, err + } + settings.DecryptedSecureJSONData, err = loader(ctx) + return settings, err +} diff --git a/pkg/registry/apis/datasource/sub_access.go b/pkg/registry/apis/datasource/sub_access.go index ea47e8425dd..5a56b8eb4a6 100644 --- a/pkg/registry/apis/datasource/sub_access.go +++ b/pkg/registry/apis/datasource/sub_access.go @@ -15,7 +15,6 @@ import ( type subAccessREST struct { builder *DataSourceAPIBuilder - getter rest.Getter } var _ = rest.Connecter(&subAccessREST{}) @@ -25,7 +24,7 @@ func (r *subAccessREST) New() runtime.Object { } func (r *subAccessREST) Destroy() { - // no-op implemenation needed for rest.Storage interface. + // no-op implementation needed for rest.Storage interface. } func (r *subAccessREST) ConnectMethods() []string { diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 2b68421a5e2..c60100b94d8 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -907,7 +907,17 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api apiService := api3.ProvideService(cfg, routeRegisterImpl, accessControl, userimplService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, service13, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, registerer, sqlStore, tracingService, resourceClient, dualwriteService, quotaService, eventualRestConfigProvider, userimplService, libraryElementService, v4, serviceImpl, dashboardActivityChannel, configProvider) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, pluginsourcesService) + v10 := _wireValue + decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, v10) + decryptStorage, err := metadata.ProvideDecryptStorage(tracer, ossKeeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, registerer) + if err != nil { + return nil, err + } + decryptService, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage) + if err != nil { + return nil, err + } + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, decryptService, accessControl, accessClient, registerer, pluginsourcesService) if err != nil { return nil, err } @@ -936,19 +946,9 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } collectionsAPIBuilder := collections.RegisterAPIService(cfg, featureToggles, sqlStore, starService, userimplService, apiserverService) webhookExtraBuilder := webhooks.ProvideWebhooksWithImages(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) - v10 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) + v11 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) pullRequestWorker := pullrequest.ProvidePullRequestWorker(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) - v11 := extras.ProvideExtraWorkers(pullRequestWorker) - v12 := _wireValue - decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, v12) - decryptStorage, err := metadata.ProvideDecryptStorage(tracer, ossKeeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, registerer) - if err != nil { - return nil, err - } - decryptService, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage) - if err != nil { - return nil, err - } + v12 := extras.ProvideExtraWorkers(pullRequestWorker) factory := github.ProvideFactory() v13 := extras.ProvideProvisioningOSSRepositoryExtras(cfg, decryptService, factory, webhookExtraBuilder, registerer) repositoryFactory, err := extras.ProvideFactoryFromConfig(cfg, v13) @@ -962,7 +962,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } quotaGetter := extras.ProvideQuotaGetter(cfg) - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, dualwriteService, usageStats, tracingService, v10, v11, repositoryFactory, connectionFactory, quotaGetter) + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, dualwriteService, usageStats, tracingService, v11, v12, repositoryFactory, connectionFactory, quotaGetter) if err != nil { return nil, err } @@ -1619,7 +1619,17 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac apiService := api3.ProvideService(cfg, routeRegisterImpl, accessControl, userimplService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, service13, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, registerer, sqlStore, tracingService, resourceClient, dualwriteService, quotaService, eventualRestConfigProvider, userimplService, libraryElementService, v4, serviceImpl, dashboardActivityChannel, configProvider) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, pluginsourcesService) + v10 := _wireValue + decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, v10) + decryptStorage, err := metadata.ProvideDecryptStorage(tracer, ossKeeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, registerer) + if err != nil { + return nil, err + } + decryptService, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage) + if err != nil { + return nil, err + } + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, decryptService, accessControl, accessClient, registerer, pluginsourcesService) if err != nil { return nil, err } @@ -1648,19 +1658,9 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } collectionsAPIBuilder := collections.RegisterAPIService(cfg, featureToggles, sqlStore, starService, userimplService, apiserverService) webhookExtraBuilder := webhooks.ProvideWebhooksWithImages(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) - v10 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) + v11 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) pullRequestWorker := pullrequest.ProvidePullRequestWorker(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) - v11 := extras.ProvideExtraWorkers(pullRequestWorker) - v12 := _wireValue - decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, v12) - decryptStorage, err := metadata.ProvideDecryptStorage(tracer, ossKeeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, registerer) - if err != nil { - return nil, err - } - decryptService, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage) - if err != nil { - return nil, err - } + v12 := extras.ProvideExtraWorkers(pullRequestWorker) factory := github.ProvideFactory() v13 := extras.ProvideProvisioningOSSRepositoryExtras(cfg, decryptService, factory, webhookExtraBuilder, registerer) repositoryFactory, err := extras.ProvideFactoryFromConfig(cfg, v13) @@ -1674,7 +1674,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } quotaGetter := extras.ProvideQuotaGetter(cfg) - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, dualwriteService, usageStats, tracingService, v10, v11, repositoryFactory, connectionFactory, quotaGetter) + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, dualwriteService, usageStats, tracingService, v11, v12, repositoryFactory, connectionFactory, quotaGetter) if err != nil { return nil, err } diff --git a/pkg/services/pluginsintegration/pluginsettings/decrypted.go b/pkg/services/pluginsintegration/pluginsettings/decrypted.go new file mode 100644 index 00000000000..b2e3bc983a5 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginsettings/decrypted.go @@ -0,0 +1,98 @@ +package pluginsettings + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/grafana/apps/secret/pkg/decrypt" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +// DecryptedSecureJSONLoader returns the decrypted secure values for a resource +// once preceding access checks have passed. Construction validates inputs; +// the returned function performs the actual decryption on demand. +type DecryptedSecureJSONLoader func(context.Context) (map[string]string, error) + +// EmptyDecryptedSecureJSONLoader is a loader that returns no secure values. +// Use it when a resource is known to have nothing to decrypt. +func EmptyDecryptedSecureJSONLoader(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} + +type ctxKey struct{} + +type secureContextShim struct { + loader DecryptedSecureJSONLoader +} + +// WithSecureContextShim must wrap a context before any request that may stash +// already-decrypted values via WithDecryptedValues. Without the shim, +// WithDecryptedValues is a no-op and GetDecryptedSecureJSONLoader falls through +// to the decrypter. +func WithSecureContextShim(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKey{}, &secureContextShim{}) +} + +// WithDecryptedValues stashes already-decrypted secure values on the context +// shim so a later GetDecryptedSecureJSONLoader call returns them directly +// without re-decrypting. No-op if WithSecureContextShim was not called. +func WithDecryptedValues(ctx context.Context, loader DecryptedSecureJSONLoader) { + if shim, ok := ctx.Value(ctxKey{}).(*secureContextShim); ok { + shim.loader = loader + } +} + +// GetDecryptedSecureJSONLoader returns a loader for the decrypted secure values +// of obj. If WithDecryptedValues populated the same context, those values are +// reused; otherwise the loader calls decrypter on demand. +func GetDecryptedSecureJSONLoader(ctx context.Context, obj utils.GrafanaMetaAccessor, decrypter decrypt.DecryptService) (DecryptedSecureJSONLoader, error) { + if shim, ok := ctx.Value(ctxKey{}).(*secureContextShim); ok && shim.loader != nil { + return shim.loader, nil + } + + secure, err := obj.GetSecureValues() + if err != nil { + return nil, err + } + if len(secure) == 0 { + return EmptyDecryptedSecureJSONLoader, nil + } + if decrypter == nil { + return nil, errors.New("no decrypter configured") + } + + // Validate and collect names up front so config errors surface here rather + // than on the first decrypt call. + names := make([]string, 0, len(secure)) + for k, ref := range secure { + if ref.Name == "" { + return nil, fmt.Errorf("missing secure value name for key: %s", k) + } + names = append(names, ref.Name) + } + + group := obj.GetGroupVersionKind().Group + namespace := obj.GetNamespace() + return func(ctx context.Context) (map[string]string, error) { + lookup, err := decrypter.Decrypt(ctx, group, namespace, names...) + if err != nil { + return nil, fmt.Errorf("error decrypting secure values: %w", err) + } + + decrypted := make(map[string]string, len(secure)) + for k, ref := range secure { + res, ok := lookup[ref.Name] + if !ok { + return nil, fmt.Errorf("unable to find secure value: %s for key: %s", ref.Name, k) + } + if err := res.Error(); err != nil { + return nil, fmt.Errorf("error decrypting secure value: %s / %w", k, err) + } + if val := res.Value(); val != nil { + decrypted[k] = val.DangerouslyExposeAndConsumeValue() + } + } + return decrypted, nil + }, nil +} diff --git a/pkg/services/pluginsintegration/pluginsettings/decrypted_test.go b/pkg/services/pluginsintegration/pluginsettings/decrypted_test.go new file mode 100644 index 00000000000..30f00dc711b --- /dev/null +++ b/pkg/services/pluginsintegration/pluginsettings/decrypted_test.go @@ -0,0 +1,313 @@ +package pluginsettings + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/apps/secret/pkg/decrypt" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +const testAPIVersion = "something.grafana.app/v7beta6" + +func TestEmptyDecryptedSecureJSONLoader(t *testing.T) { + out, err := EmptyDecryptedSecureJSONLoader(context.Background()) + require.NoError(t, err) + require.Empty(t, out) + require.NotNil(t, out) +} + +func TestSecureContextShim(t *testing.T) { + t.Run("WithDecryptedValues without shim is a no-op", func(t *testing.T) { + ctx := context.Background() + // Should not panic; later GetDecryptedSecureJSONLoader must fall through + // to the decrypter. + WithDecryptedValues(ctx, func(context.Context) (map[string]string, error) { + t.Fatal("loader should not be invoked") + return nil, nil + }) + + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", nil) + loader, err := GetDecryptedSecureJSONLoader(ctx, obj, nil) + require.NoError(t, err) + // No secure values configured, so we get the empty loader. + out, err := loader(ctx) + require.NoError(t, err) + require.Empty(t, out) + }) + + t.Run("WithSecureContextShim + WithDecryptedValues short-circuits decrypter", func(t *testing.T) { + ctx := WithSecureContextShim(context.Background()) + + stashed := map[string]string{"token": "abc"} + WithDecryptedValues(ctx, func(context.Context) (map[string]string, error) { + return stashed, nil + }) + + // Even with a decrypter and secure values present, the stashed loader wins. + dec := &stubDecrypter{} + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "secret-token"}, + }) + loader, err := GetDecryptedSecureJSONLoader(ctx, obj, dec) + require.NoError(t, err) + out, err := loader(ctx) + require.NoError(t, err) + require.Equal(t, stashed, out) + require.Zero(t, dec.calls, "decrypter must not be called when values were already stashed") + }) + + t.Run("WithSecureContextShim without WithDecryptedValues falls through", func(t *testing.T) { + ctx := WithSecureContextShim(context.Background()) + + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", nil) + loader, err := GetDecryptedSecureJSONLoader(ctx, obj, nil) + require.NoError(t, err) + out, err := loader(ctx) + require.NoError(t, err) + require.Empty(t, out) + }) +} + +func TestGetDecryptedSecureJSONLoader(t *testing.T) { + t.Run("no secure values returns empty loader", func(t *testing.T) { + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{}) + loader, err := GetDecryptedSecureJSONLoader(context.Background(), obj, nil) + require.NoError(t, err) + out, err := loader(context.Background()) + require.NoError(t, err) + require.Empty(t, out) + }) + + t.Run("error reading secure values is returned", func(t *testing.T) { + // Set 'secure' to an unsupported type so GetSecureValues fails. + u := &unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"namespace": "ns"}, + "secure": 42, + }} + obj, err := utils.MetaAccessor(u) + require.NoError(t, err) + + _, err = GetDecryptedSecureJSONLoader(context.Background(), obj, &stubDecrypter{}) + require.Error(t, err) + }) + + t.Run("secure values present but no decrypter errors", func(t *testing.T) { + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "secret-token"}, + }) + _, err := GetDecryptedSecureJSONLoader(context.Background(), obj, nil) + require.ErrorContains(t, err, "no decrypter configured") + }) + + t.Run("missing secure value name surfaces during construction", func(t *testing.T) { + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: ""}, + }) + _, err := GetDecryptedSecureJSONLoader(context.Background(), obj, &stubDecrypter{}) + require.ErrorContains(t, err, "missing secure value name") + require.ErrorContains(t, err, "token") + }) +} + +func TestDecryptLoader_Success(t *testing.T) { + val := secretv1beta1.NewExposedSecureValue("super-secret") + dec := &stubDecrypter{ + results: map[string]decrypt.DecryptResult{ + "secret-token": decrypt.NewDecryptResultValue(&val), + }, + } + + obj := newTestObject(t, "myns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "secret-token"}, + }) + + loader, err := GetDecryptedSecureJSONLoader(context.Background(), obj, dec) + require.NoError(t, err) + + out, err := loader(context.Background()) + require.NoError(t, err) + require.Equal(t, map[string]string{"token": "super-secret"}, out) + + require.Equal(t, 1, dec.calls) + require.Equal(t, "something.grafana.app", dec.gotGroup) + require.Equal(t, "myns", dec.gotNamespace) + require.Equal(t, []string{"secret-token"}, dec.gotNames) +} + +func TestDecryptLoader_MultipleValues(t *testing.T) { + tokenVal := secretv1beta1.NewExposedSecureValue("token-v") + pwVal := secretv1beta1.NewExposedSecureValue("pw-v") + dec := &stubDecrypter{ + results: map[string]decrypt.DecryptResult{ + "name-token": decrypt.NewDecryptResultValue(&tokenVal), + "name-password": decrypt.NewDecryptResultValue(&pwVal), + }, + } + + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "name-token"}, + "password": {Name: "name-password"}, + }) + + loader, err := GetDecryptedSecureJSONLoader(context.Background(), obj, dec) + require.NoError(t, err) + + out, err := loader(context.Background()) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "token": "token-v", + "password": "pw-v", + }, out) + + // Both ref names were forwarded; map iteration order isn't stable, so + // compare as a set. + require.ElementsMatch(t, []string{"name-token", "name-password"}, dec.gotNames) +} + +func TestDecryptLoader_DecrypterErrorPropagated(t *testing.T) { + dec := &stubDecrypter{err: errors.New("boom")} + + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "secret-token"}, + }) + loader, err := GetDecryptedSecureJSONLoader(context.Background(), obj, dec) + require.NoError(t, err) + + _, err = loader(context.Background()) + require.ErrorContains(t, err, "error decrypting secure values") + require.ErrorContains(t, err, "boom") +} + +func TestDecryptLoader_MissingResultForName(t *testing.T) { + dec := &stubDecrypter{ + // Decrypter returns no entry for the requested name. + results: map[string]decrypt.DecryptResult{}, + } + + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "secret-token"}, + }) + loader, err := GetDecryptedSecureJSONLoader(context.Background(), obj, dec) + require.NoError(t, err) + + _, err = loader(context.Background()) + require.ErrorContains(t, err, "unable to find secure value") + require.ErrorContains(t, err, "secret-token") + require.ErrorContains(t, err, "token") +} + +func TestDecryptLoader_PerValueErrorPropagated(t *testing.T) { + dec := &stubDecrypter{ + results: map[string]decrypt.DecryptResult{ + "secret-token": decrypt.NewDecryptResultErr(errors.New("forbidden")), + }, + } + + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "secret-token"}, + }) + loader, err := GetDecryptedSecureJSONLoader(context.Background(), obj, dec) + require.NoError(t, err) + + _, err = loader(context.Background()) + require.ErrorContains(t, err, "error decrypting secure value") + require.ErrorContains(t, err, "token") + require.ErrorContains(t, err, "forbidden") +} + +func TestDecryptLoader_NilValueIsSkipped(t *testing.T) { + // A successful result with no value attached should not appear in the + // decrypted map (and must not panic on DangerouslyExposeAndConsumeValue). + dec := &stubDecrypter{ + results: map[string]decrypt.DecryptResult{ + "secret-token": decrypt.NewDecryptResultValue(nil), + }, + } + + obj := newTestObject(t, "ns", testAPIVersion, "DataSource", common.InlineSecureValues{ + "token": {Name: "secret-token"}, + }) + loader, err := GetDecryptedSecureJSONLoader(context.Background(), obj, dec) + require.NoError(t, err) + + out, err := loader(context.Background()) + require.NoError(t, err) + require.Empty(t, out) +} + +func TestDecryptLoader_GroupAndNamespaceFromTypedObject(t *testing.T) { + // Use a typed runtime.Object so GetGroupVersionKind takes the runtime path. + val := secretv1beta1.NewExposedSecureValue("v") + dec := &stubDecrypter{ + results: map[string]decrypt.DecryptResult{ + "n": decrypt.NewDecryptResultValue(&val), + }, + } + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(schema.GroupVersionKind{Group: "g.example.com", Version: "v1", Kind: "Thing"}) + u.SetNamespace("ns-1") + u.Object["secure"] = common.InlineSecureValues{"k": {Name: "n"}} + + meta, err := utils.MetaAccessor(u) + require.NoError(t, err) + + loader, err := GetDecryptedSecureJSONLoader(context.Background(), meta, dec) + require.NoError(t, err) + _, err = loader(context.Background()) + require.NoError(t, err) + require.Equal(t, "g.example.com", dec.gotGroup) + require.Equal(t, "ns-1", dec.gotNamespace) +} + +// stubDecrypter is a test-only DecryptService that records its inputs and +// returns canned results. +type stubDecrypter struct { + gotGroup string + gotNamespace string + gotNames []string + calls int + results map[string]decrypt.DecryptResult + err error +} + +func (s *stubDecrypter) Decrypt(_ context.Context, group, namespace string, names ...string) (map[string]decrypt.DecryptResult, error) { + s.calls++ + s.gotGroup = group + s.gotNamespace = namespace + s.gotNames = append([]string(nil), names...) + if s.err != nil { + return nil, s.err + } + return s.results, nil +} + +func newTestObject(t *testing.T, namespace string, apiVersion string, kind string, secure common.InlineSecureValues) utils.GrafanaMetaAccessor { + t.Helper() + obj := map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]any{ + "namespace": namespace, + }, + "spec": map[string]any{ + "hello": "world", + }, + } + if secure != nil { + // Store as the typed map; GetSecureValues handles the direct cast path. + obj["secure"] = secure + } + u := &unstructured.Unstructured{Object: obj} + meta, err := utils.MetaAccessor(u) + require.NoError(t, err) + return meta +} diff --git a/pkg/services/pluginsintegration/pluginsettings/models.go b/pkg/services/pluginsintegration/pluginsettings/models.go index 8f4cfced30f..762c8c247ee 100644 --- a/pkg/services/pluginsintegration/pluginsettings/models.go +++ b/pkg/services/pluginsintegration/pluginsettings/models.go @@ -1,7 +1,6 @@ package pluginsettings import ( - "context" "errors" "time" ) @@ -10,9 +9,6 @@ var ( ErrPluginSettingNotFound = errors.New("plugin setting not found") ) -// Get access to the decrypted secure values after other checks have passed -type SecureJsonGetter func(context.Context) (map[string]string, error) - type DTO struct { ID int64 OrgID int64