DataSources: Load secure values from decrypter (#124515)

This commit is contained in:
Ryan McKinley 2026-05-11 18:58:44 +03:00 committed by GitHub
parent abcfb01704
commit 29939bcd79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 564 additions and 106 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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"

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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)
}

View file

@ -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)

View file

@ -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
}

View file

@ -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 {

56
pkg/server/wire_gen.go generated
View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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