Provisioning: Begin using secrets store (#108044)

- Provisioning: Begin using secrets store
- Refactor integration with secrets store
- Add back the legacy service
- Separate concerns for encrypt and decrypt
- Handle update within Encrypt function
- Add interface for secure value service
- Add feature flag for using secrets service
- Add the dual service for temporary solution.

* Add first integration tests for encrypted tokens
* Add integration test for app platform secrets
* Validate it has the name or not
* Create wire provider
* Always save to the secret if provided secret

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
Co-authored-by: Roberto Jiménez Sánchez <jszroberto@gmail.com>
kristina/correlation-migrate
Stephanie Hingtgen 6 days ago committed by GitHub
parent 68b9a5f57c
commit d39a47a89b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/grafana-data/src/types/featureToggles.gen.ts
  2. 4
      pkg/registry/apis/provisioning/controller/repository.go
  3. 60
      pkg/registry/apis/provisioning/register.go
  4. 37
      pkg/registry/apis/provisioning/secrets/legacy_secret.go
  5. 154
      pkg/registry/apis/provisioning/secrets/legacy_secret_mock.go
  6. 111
      pkg/registry/apis/provisioning/secrets/mocks/decrypt_service_mock.go
  7. 98
      pkg/registry/apis/provisioning/secrets/repository.go
  8. 158
      pkg/registry/apis/provisioning/secrets/repository_secrets_mock.go
  9. 237
      pkg/registry/apis/provisioning/secrets/repository_test.go
  10. 108
      pkg/registry/apis/provisioning/secrets/secret.go
  11. 73
      pkg/registry/apis/provisioning/secrets/secret_mock.go
  12. 412
      pkg/registry/apis/provisioning/secrets/secret_test.go
  13. 226
      pkg/registry/apis/provisioning/secrets/secure_value_mock.go
  14. 25
      pkg/registry/apis/provisioning/webhooks/register.go
  15. 6
      pkg/registry/apis/provisioning/webhooks/repository.go
  16. 196
      pkg/registry/apis/provisioning/webhooks/repository_test.go
  17. 2
      pkg/registry/apis/secret/decrypt/service.go
  18. 2
      pkg/registry/apis/wireset.go
  19. 4
      pkg/server/test_env.go
  20. 4
      pkg/server/wire.go
  21. 180
      pkg/server/wire_gen.go
  22. 177
      pkg/services/featuremgmt/feature_toggles_mock.go
  23. 1
      pkg/services/featuremgmt/models.go
  24. 7
      pkg/services/featuremgmt/registry.go
  25. 1
      pkg/services/featuremgmt/toggles_gen.csv
  26. 4
      pkg/services/featuremgmt/toggles_gen.go
  27. 16
      pkg/services/featuremgmt/toggles_gen.json
  28. 7
      pkg/tests/apis/provisioning/helper_test.go
  29. 447
      pkg/tests/apis/provisioning/secrets_test.go
  30. 24
      pkg/tests/apis/provisioning/testdata/git-readonly.json.tmpl

@ -210,6 +210,10 @@ export interface FeatureToggles {
*/
provisioning?: boolean;
/**
* Experimental feature to use the secrets service for provisioning instead of the legacy secrets
*/
provisioningSecretsService?: boolean;
/**
* Start an additional https handler and write kubectl options
*/
grafanaAPIServerEnsureKubectlAccess?: boolean;

@ -25,7 +25,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
)
@ -60,7 +59,6 @@ type RepositoryController struct {
repoSynced cache.InformerSynced
parsers resources.ParserFactory
logger logging.Logger
secrets secrets.Service
dualwrite dualwrite.Service
jobs jobs.Queue
@ -87,7 +85,6 @@ func NewRepositoryController(
clients resources.ClientFactory,
tester RepositoryTester,
jobs jobs.Queue,
secrets secrets.Service,
dualwrite dualwrite.Service,
) (*RepositoryController, error) {
rc := &RepositoryController{
@ -110,7 +107,6 @@ func NewRepositoryController(
tester: tester,
jobs: jobs,
logger: logging.DefaultLogger.With("logger", loggerName),
secrets: secrets,
dualwrite: dualwrite,
}

@ -57,7 +57,6 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
grafanasecrets "github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@ -89,17 +88,17 @@ type APIBuilder struct {
jobs.Queue
jobs.Store
}
jobHistory jobs.History
tester *RepositoryTester
resourceLister resources.ResourceLister
repositoryLister listers.RepositoryLister
legacyMigrator legacy.LegacyMigrator
storageStatus dualwrite.Service
unified resource.ResourceClient
secrets secrets.Service
client client.ProvisioningV0alpha1Interface
access authlib.AccessChecker
statusPatcher *controller.RepositoryStatusPatcher
jobHistory jobs.History
tester *RepositoryTester
resourceLister resources.ResourceLister
repositoryLister listers.RepositoryLister
legacyMigrator legacy.LegacyMigrator
storageStatus dualwrite.Service
unified resource.ResourceClient
repositorySecrets secrets.RepositorySecrets
client client.ProvisioningV0alpha1Interface
access authlib.AccessChecker
statusPatcher *controller.RepositoryStatusPatcher
// Extras provides additional functionality to the API.
extras []Extra
availableRepositoryTypes map[provisioning.RepositoryType]bool
@ -117,7 +116,7 @@ func NewAPIBuilder(
ghFactory *github.Factory,
legacyMigrator legacy.LegacyMigrator,
storageStatus dualwrite.Service,
secrets secrets.Service,
repositorySecrets secrets.RepositorySecrets,
access authlib.AccessChecker,
tracer tracing.Tracer,
extraBuilders []ExtraBuilder,
@ -139,7 +138,7 @@ func NewAPIBuilder(
legacyMigrator: legacyMigrator,
storageStatus: storageStatus,
unified: unified,
secrets: secrets,
repositorySecrets: repositorySecrets,
access: access,
jobHistory: jobs.NewJobHistoryCache(),
availableRepositoryTypes: map[provisioning.RepositoryType]bool{
@ -177,8 +176,7 @@ func RegisterAPIService(
legacyMigrator legacy.LegacyMigrator,
storageStatus dualwrite.Service,
usageStatsService usagestats.Service,
// FIXME: use multi-tenant service when one exists. In this state, we can't make this a multi-tenant service!
secretsSvc grafanasecrets.Service,
repositorySecrets secrets.RepositorySecrets,
tracer tracing.Tracer,
extraBuilders []ExtraBuilder,
) (*APIBuilder, error) {
@ -195,7 +193,8 @@ func RegisterAPIService(
filepath.Join(cfg.DataPath, "clone"), // where repositories are cloned (temporarialy for now)
configProvider, ghFactory,
legacyMigrator, storageStatus,
secrets.NewSingleTenant(secretsSvc), access,
repositorySecrets,
access,
tracer,
extraBuilders,
)
@ -466,11 +465,11 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
}
if err := b.encryptGithubToken(ctx, r); err != nil {
return fmt.Errorf("failed to encrypt secrets: %w", err)
return fmt.Errorf("failed to encrypt github secrets: %w", err)
}
if err := b.encryptGitToken(ctx, r); err != nil {
return fmt.Errorf("failed to encrypt secrets: %w", err)
return fmt.Errorf("failed to encrypt git secrets: %w", err)
}
// Mutate the repository with any extra mutators
@ -485,28 +484,32 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
// TODO: move this to a more appropriate place
func (b *APIBuilder) encryptGithubToken(ctx context.Context, repo *provisioning.Repository) error {
var err error
if repo.Spec.GitHub != nil &&
repo.Spec.GitHub.Token != "" {
repo.Spec.GitHub.EncryptedToken, err = b.secrets.Encrypt(ctx, []byte(repo.Spec.GitHub.Token))
secretName := repo.Name + "-github-token"
nameOrValue, err := b.repositorySecrets.Encrypt(ctx, repo, secretName, repo.Spec.GitHub.Token)
if err != nil {
return err
}
repo.Spec.GitHub.Token = ""
repo.Spec.GitHub.EncryptedToken = nameOrValue
}
return nil
}
// TODO: move this to a more appropriate place
// TODO: make this one more generic
func (b *APIBuilder) encryptGitToken(ctx context.Context, repo *provisioning.Repository) error {
var err error
if repo.Spec.Git != nil &&
repo.Spec.Git.Token != "" {
repo.Spec.Git.EncryptedToken, err = b.secrets.Encrypt(ctx, []byte(repo.Spec.Git.Token))
if repo.Spec.Git != nil && repo.Spec.Git.Token != "" {
secretName := repo.Name + "-git-token"
nameOrValue, err := b.repositorySecrets.Encrypt(ctx, repo, secretName, repo.Spec.Git.Token)
if err != nil {
return err
}
repo.Spec.Git.EncryptedToken = nameOrValue
repo.Spec.Git.Token = ""
}
@ -701,7 +704,6 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH
b.clients,
&repository.Tester{},
b.jobs,
b.secrets,
b.storageStatus,
)
if err != nil {
@ -1215,8 +1217,8 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
case provisioning.GitRepositoryType:
// Decrypt token if needed
token := r.Spec.Git.Token
if token == "" {
decrypted, err := b.secrets.Decrypt(ctx, r.Spec.Git.EncryptedToken)
if token == "" && len(r.Spec.Git.EncryptedToken) > 0 {
decrypted, err := b.repositorySecrets.Decrypt(ctx, r, string(r.Spec.Git.EncryptedToken))
if err != nil {
return nil, fmt.Errorf("decrypt git token: %w", err)
}
@ -1242,7 +1244,7 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
// Decrypt GitHub token if needed
ghToken := ghCfg.Token
if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
decrypted, err := b.secrets.Decrypt(ctx, ghCfg.EncryptedToken)
decrypted, err := b.repositorySecrets.Decrypt(ctx, r, string(ghCfg.EncryptedToken))
if err != nil {
return nil, fmt.Errorf("decrypt github token: %w", err)
}

@ -0,0 +1,37 @@
package secrets
import (
"context"
"github.com/grafana/grafana/pkg/services/secrets"
)
// A secrets encryption service. It only operates on values, no names or similar.
// It is likely we will need to change this when the multi-tenant service comes around.
//
// FIXME: this is a temporary service/package until we can make use of
// the new secrets service in app platform.
//
//go:generate mockery --name LegacyService --structname MockLegacyService --inpackage --filename legacy_secret_mock.go --with-expecter
type LegacyService interface {
Encrypt(ctx context.Context, data []byte) ([]byte, error)
Decrypt(ctx context.Context, data []byte) ([]byte, error)
}
var _ LegacyService = (*singleTenant)(nil)
type singleTenant struct {
inner secrets.Service
}
func NewSingleTenant(svc secrets.Service) LegacyService {
return &singleTenant{svc}
}
func (s *singleTenant) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
return s.inner.Encrypt(ctx, data, secrets.WithoutScope())
}
func (s *singleTenant) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
return s.inner.Decrypt(ctx, data)
}

@ -0,0 +1,154 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package secrets
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockLegacyService is an autogenerated mock type for the LegacyService type
type MockLegacyService struct {
mock.Mock
}
type MockLegacyService_Expecter struct {
mock *mock.Mock
}
func (_m *MockLegacyService) EXPECT() *MockLegacyService_Expecter {
return &MockLegacyService_Expecter{mock: &_m.Mock}
}
// Decrypt provides a mock function with given fields: ctx, data
func (_m *MockLegacyService) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
ret := _m.Called(ctx, data)
if len(ret) == 0 {
panic("no return value specified for Decrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
return rf(ctx, data)
}
if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
r0 = rf(ctx, data)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
r1 = rf(ctx, data)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockLegacyService_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt'
type MockLegacyService_Decrypt_Call struct {
*mock.Call
}
// Decrypt is a helper method to define mock.On call
// - ctx context.Context
// - data []byte
func (_e *MockLegacyService_Expecter) Decrypt(ctx interface{}, data interface{}) *MockLegacyService_Decrypt_Call {
return &MockLegacyService_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, data)}
}
func (_c *MockLegacyService_Decrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockLegacyService_Decrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]byte))
})
return _c
}
func (_c *MockLegacyService_Decrypt_Call) Return(_a0 []byte, _a1 error) *MockLegacyService_Decrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockLegacyService_Decrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockLegacyService_Decrypt_Call {
_c.Call.Return(run)
return _c
}
// Encrypt provides a mock function with given fields: ctx, data
func (_m *MockLegacyService) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
ret := _m.Called(ctx, data)
if len(ret) == 0 {
panic("no return value specified for Encrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
return rf(ctx, data)
}
if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
r0 = rf(ctx, data)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
r1 = rf(ctx, data)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockLegacyService_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt'
type MockLegacyService_Encrypt_Call struct {
*mock.Call
}
// Encrypt is a helper method to define mock.On call
// - ctx context.Context
// - data []byte
func (_e *MockLegacyService_Expecter) Encrypt(ctx interface{}, data interface{}) *MockLegacyService_Encrypt_Call {
return &MockLegacyService_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, data)}
}
func (_c *MockLegacyService_Encrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockLegacyService_Encrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]byte))
})
return _c
}
func (_c *MockLegacyService_Encrypt_Call) Return(_a0 []byte, _a1 error) *MockLegacyService_Encrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockLegacyService_Encrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockLegacyService_Encrypt_Call {
_c.Call.Return(run)
return _c
}
// NewMockLegacyService creates a new instance of MockLegacyService. 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 NewMockLegacyService(t interface {
mock.TestingT
Cleanup(func())
}) *MockLegacyService {
mock := &MockLegacyService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -0,0 +1,111 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package mocks
import (
context "context"
service "github.com/grafana/grafana/pkg/registry/apis/secret/service"
mock "github.com/stretchr/testify/mock"
)
// MockDecryptService is an autogenerated mock type for the DecryptService type
type MockDecryptService struct {
mock.Mock
}
type MockDecryptService_Expecter struct {
mock *mock.Mock
}
func (_m *MockDecryptService) EXPECT() *MockDecryptService_Expecter {
return &MockDecryptService_Expecter{mock: &_m.Mock}
}
// Decrypt provides a mock function with given fields: ctx, namespace, names
func (_m *MockDecryptService) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]service.DecryptResult, error) {
_va := make([]interface{}, len(names))
for _i := range names {
_va[_i] = names[_i]
}
var _ca []interface{}
_ca = append(_ca, ctx, namespace)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for Decrypt")
}
var r0 map[string]service.DecryptResult
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, ...string) (map[string]service.DecryptResult, error)); ok {
return rf(ctx, namespace, names...)
}
if rf, ok := ret.Get(0).(func(context.Context, string, ...string) map[string]service.DecryptResult); ok {
r0 = rf(ctx, namespace, names...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]service.DecryptResult)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, ...string) error); ok {
r1 = rf(ctx, namespace, names...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockDecryptService_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt'
type MockDecryptService_Decrypt_Call struct {
*mock.Call
}
// Decrypt is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - names ...string
func (_e *MockDecryptService_Expecter) Decrypt(ctx interface{}, namespace interface{}, names ...interface{}) *MockDecryptService_Decrypt_Call {
return &MockDecryptService_Decrypt_Call{Call: _e.mock.On("Decrypt",
append([]interface{}{ctx, namespace}, names...)...)}
}
func (_c *MockDecryptService_Decrypt_Call) Run(run func(ctx context.Context, namespace string, names ...string)) *MockDecryptService_Decrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]string, len(args)-2)
for i, a := range args[2:] {
if a != nil {
variadicArgs[i] = a.(string)
}
}
run(args[0].(context.Context), args[1].(string), variadicArgs...)
})
return _c
}
func (_c *MockDecryptService_Decrypt_Call) Return(_a0 map[string]service.DecryptResult, _a1 error) *MockDecryptService_Decrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockDecryptService_Decrypt_Call) RunAndReturn(run func(context.Context, string, ...string) (map[string]service.DecryptResult, error)) *MockDecryptService_Decrypt_Call {
_c.Call.Return(run)
return _c
}
// NewMockDecryptService creates a new instance of MockDecryptService. 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 NewMockDecryptService(t interface {
mock.TestingT
Cleanup(func())
}) *MockDecryptService {
mock := &MockDecryptService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -0,0 +1,98 @@
package secrets
import (
"context"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/service"
"github.com/grafana/grafana/pkg/services/featuremgmt"
grafanasecrets "github.com/grafana/grafana/pkg/services/secrets"
)
func ProvideRepositorySecrets(
features featuremgmt.FeatureToggles,
legacySecretsSvc grafanasecrets.Service,
secretsSvc *service.SecureValueService,
decryptSvc service.DecryptService,
) RepositorySecrets {
return NewRepositorySecrets(features, NewSecretsService(secretsSvc, decryptSvc), NewSingleTenant(legacySecretsSvc))
}
//go:generate mockery --name RepositorySecrets --structname MockRepositorySecrets --inpackage --filename repository_secrets_mock.go --with-expecter
type RepositorySecrets interface {
Encrypt(ctx context.Context, r *provisioning.Repository, name string, data string) (nameOrValue []byte, err error)
Decrypt(ctx context.Context, r *provisioning.Repository, nameOrValue string) (data []byte, err error)
}
// repositorySecrets provides a unified interface for encrypting and decrypting repository secrets,
// supporting both the legacy and new secrets services. The active backend is determined by the
// FlagProvisioningSecretsService feature flag:
// - If enabled, operations use the new secrets service.
// - If disabled, operations use the legacy secrets service.
//
// This abstraction enables a seamless migration path between secret backends without breaking
// existing functionality. Once migration is complete and the legacy service is deprecated,
// this wrapper should be removed.
type repositorySecrets struct {
features featuremgmt.FeatureToggles
secretsSvc Service
legacySecrets LegacyService
}
func NewRepositorySecrets(features featuremgmt.FeatureToggles, secretsSvc Service, legacySecrets LegacyService) RepositorySecrets {
return &repositorySecrets{
features: features,
secretsSvc: secretsSvc,
legacySecrets: legacySecrets,
}
}
// Encrypt encrypts the data and returns the name or value of the encrypted data
// If the feature flag is disabled, it uses the legacy secrets service
// If the feature flag is enabled, it uses the secrets service
func (s *repositorySecrets) Encrypt(ctx context.Context, r *provisioning.Repository, name string, data string) (nameOrValue []byte, err error) {
if s.features.IsEnabled(ctx, featuremgmt.FlagProvisioningSecretsService) {
encrypted, err := s.secretsSvc.Encrypt(ctx, r.GetNamespace(), name, data)
if err != nil {
return nil, err
}
return []byte(encrypted), err
}
encrypted, err := s.legacySecrets.Encrypt(ctx, []byte(data))
if err != nil {
return nil, err
}
return encrypted, nil
}
// Decrypt attempts to retrieve and decrypt secret data for a repository.
// If the provisioning secrets service feature flag is enabled, it tries the new secrets service first.
// - On success, returns the decrypted data.
// - On failure, falls back to the legacy secrets service.
//
// If the feature flag is disabled, it tries the legacy secrets service first.
// - On success, returns the decrypted data.
// - On failure, falls back to the new secrets service.
//
// This dual-path logic is intended to support migration between secret backends.
func (s *repositorySecrets) Decrypt(ctx context.Context, r *provisioning.Repository, nameOrValue string) ([]byte, error) {
if s.features.IsEnabled(ctx, featuremgmt.FlagProvisioningSecretsService) {
data, err := s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
if err == nil {
return data, nil
}
// If the new service fails, fall back to legacy
return s.legacySecrets.Decrypt(ctx, []byte(nameOrValue))
}
// If the new service is disabled, use the legacy service first
data, err := s.legacySecrets.Decrypt(ctx, []byte(nameOrValue))
if err == nil {
return data, nil
}
return s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
}

@ -0,0 +1,158 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package secrets
import (
context "context"
v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockRepositorySecrets is an autogenerated mock type for the RepositorySecrets type
type MockRepositorySecrets struct {
mock.Mock
}
type MockRepositorySecrets_Expecter struct {
mock *mock.Mock
}
func (_m *MockRepositorySecrets) EXPECT() *MockRepositorySecrets_Expecter {
return &MockRepositorySecrets_Expecter{mock: &_m.Mock}
}
// Decrypt provides a mock function with given fields: ctx, r, nameOrValue
func (_m *MockRepositorySecrets) Decrypt(ctx context.Context, r *v0alpha1.Repository, nameOrValue string) ([]byte, error) {
ret := _m.Called(ctx, r, nameOrValue)
if len(ret) == 0 {
panic("no return value specified for Decrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string) ([]byte, error)); ok {
return rf(ctx, r, nameOrValue)
}
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string) []byte); ok {
r0 = rf(ctx, r, nameOrValue)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Repository, string) error); ok {
r1 = rf(ctx, r, nameOrValue)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockRepositorySecrets_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt'
type MockRepositorySecrets_Decrypt_Call struct {
*mock.Call
}
// Decrypt is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Repository
// - nameOrValue string
func (_e *MockRepositorySecrets_Expecter) Decrypt(ctx interface{}, r interface{}, nameOrValue interface{}) *MockRepositorySecrets_Decrypt_Call {
return &MockRepositorySecrets_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, r, nameOrValue)}
}
func (_c *MockRepositorySecrets_Decrypt_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository, nameOrValue string)) *MockRepositorySecrets_Decrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Repository), args[2].(string))
})
return _c
}
func (_c *MockRepositorySecrets_Decrypt_Call) Return(data []byte, err error) *MockRepositorySecrets_Decrypt_Call {
_c.Call.Return(data, err)
return _c
}
func (_c *MockRepositorySecrets_Decrypt_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository, string) ([]byte, error)) *MockRepositorySecrets_Decrypt_Call {
_c.Call.Return(run)
return _c
}
// Encrypt provides a mock function with given fields: ctx, r, name, data
func (_m *MockRepositorySecrets) Encrypt(ctx context.Context, r *v0alpha1.Repository, name string, data string) ([]byte, error) {
ret := _m.Called(ctx, r, name, data)
if len(ret) == 0 {
panic("no return value specified for Encrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string, string) ([]byte, error)); ok {
return rf(ctx, r, name, data)
}
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string, string) []byte); ok {
r0 = rf(ctx, r, name, data)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Repository, string, string) error); ok {
r1 = rf(ctx, r, name, data)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockRepositorySecrets_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt'
type MockRepositorySecrets_Encrypt_Call struct {
*mock.Call
}
// Encrypt is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Repository
// - name string
// - data string
func (_e *MockRepositorySecrets_Expecter) Encrypt(ctx interface{}, r interface{}, name interface{}, data interface{}) *MockRepositorySecrets_Encrypt_Call {
return &MockRepositorySecrets_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, r, name, data)}
}
func (_c *MockRepositorySecrets_Encrypt_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository, name string, data string)) *MockRepositorySecrets_Encrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Repository), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockRepositorySecrets_Encrypt_Call) Return(nameOrValue []byte, err error) *MockRepositorySecrets_Encrypt_Call {
_c.Call.Return(nameOrValue, err)
return _c
}
func (_c *MockRepositorySecrets_Encrypt_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository, string, string) ([]byte, error)) *MockRepositorySecrets_Encrypt_Call {
_c.Call.Return(run)
return _c
}
// NewMockRepositorySecrets creates a new instance of MockRepositorySecrets. 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 NewMockRepositorySecrets(t interface {
mock.TestingT
Cleanup(func())
}) *MockRepositorySecrets {
mock := &MockRepositorySecrets{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -0,0 +1,237 @@
package secrets
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type testSetup struct {
rs RepositorySecrets
mockFeatures *featuremgmt.MockFeatureToggles
mockSecrets *MockService
mockLegacy *MockLegacyService
repo *provisioning.Repository
ctx context.Context
}
func setupTest(t *testing.T, namespace string) *testSetup {
mockFeatures := featuremgmt.NewMockFeatureToggles(t)
mockSecrets := NewMockService(t)
mockLegacy := NewMockLegacyService(t)
rs := NewRepositorySecrets(mockFeatures, mockSecrets, mockLegacy)
repo := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: namespace,
},
}
return &testSetup{
rs: rs,
mockFeatures: mockFeatures,
mockSecrets: mockSecrets,
mockLegacy: mockLegacy,
repo: repo,
ctx: context.Background(),
}
}
func (s *testSetup) expectFeatureFlag(enabled bool) {
s.mockFeatures.EXPECT().IsEnabled(
mock.AnythingOfType("context.backgroundCtx"),
featuremgmt.FlagProvisioningSecretsService,
).Return(enabled)
}
func TestRepositorySecrets_Encrypt(t *testing.T) {
tests := []struct {
name string
namespace string
featureEnabled bool
setupMocks func(*testSetup)
expectedResult []byte
expectedError string
}{
{
name: "new service success",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Encrypt(s.ctx, "test-namespace", "test-secret", "secret-data").Return("encrypted-name", nil)
},
expectedResult: []byte("encrypted-name"),
},
{
name: "legacy service success",
namespace: "test-namespace",
featureEnabled: false,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Encrypt(s.ctx, []byte("secret-data")).Return([]byte("encrypted-legacy-data"), nil)
},
expectedResult: []byte("encrypted-legacy-data"),
},
{
name: "new service error",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Encrypt(s.ctx, "test-namespace", "test-secret", "secret-data").Return("", errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "legacy service error",
namespace: "test-namespace",
featureEnabled: false,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Encrypt(s.ctx, []byte("secret-data")).Return(nil, errors.New("legacy encryption failed"))
},
expectedError: "legacy encryption failed",
},
{
name: "empty namespace handling",
namespace: "",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Encrypt(s.ctx, "", "test-secret", "secret-data").Return("encrypted", nil)
},
expectedResult: []byte("encrypted"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup := setupTest(t, tt.namespace)
tt.setupMocks(setup)
result, err := setup.rs.Encrypt(setup.ctx, setup.repo, "test-secret", "secret-data")
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, result)
}
})
}
}
func TestRepositorySecrets_Decrypt(t *testing.T) {
tests := []struct {
name string
namespace string
featureEnabled bool
setupMocks func(*testSetup)
expectedResult []byte
expectedError string
}{
{
name: "new service success",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return([]byte("decrypted-data"), nil)
},
expectedResult: []byte("decrypted-data"),
},
{
name: "legacy service success",
namespace: "test-namespace",
featureEnabled: false,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return([]byte("decrypted-legacy-data"), nil)
},
expectedResult: []byte("decrypted-legacy-data"),
},
{
name: "new service fails, fallback to legacy succeeds",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return(nil, errors.New("new service failed"))
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return([]byte("decrypted-fallback-data"), nil)
},
expectedResult: []byte("decrypted-fallback-data"),
},
{
name: "legacy service fails, fallback to new succeeds",
namespace: "test-namespace",
featureEnabled: false,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return(nil, errors.New("legacy service failed"))
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return([]byte("decrypted-new-data"), nil)
},
expectedResult: []byte("decrypted-new-data"),
},
{
name: "both services fail (feature flag enabled)",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return(nil, errors.New("new service failed"))
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return(nil, errors.New("legacy service failed"))
},
expectedError: "legacy service failed",
},
{
name: "both services fail (feature flag disabled)",
namespace: "test-namespace",
featureEnabled: false,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return(nil, errors.New("legacy service failed"))
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return(nil, errors.New("new service failed"))
},
expectedError: "new service failed",
},
{
name: "custom namespace handling",
namespace: "custom-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Decrypt(s.ctx, "custom-namespace", "encrypted-value").Return([]byte("test-data"), nil)
},
expectedResult: []byte("test-data"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup := setupTest(t, tt.namespace)
tt.setupMocks(setup)
result, err := setup.rs.Decrypt(setup.ctx, setup.repo, "encrypted-value")
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, result)
}
})
}
}

@ -2,36 +2,108 @@ package secrets
import (
"context"
"errors"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/authlib/types"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
grafanasecrets "github.com/grafana/grafana/pkg/registry/apis/secret/service"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// A secrets encryption service. It only operates on values, no names or similar.
// It is likely we will need to change this when the multi-tenant service comes around.
//
// FIXME: this is a temporary service/package until we can make use of
// the new secrets service in app platform.
//
const svcName = "provisioning"
//go:generate mockery --name SecureValueService --structname MockSecureValueService --inpackage --filename secure_value_mock.go --with-expecter
type SecureValueService interface {
Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error)
Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error)
Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error)
}
//go:generate mockery --name Service --structname MockService --inpackage --filename secret_mock.go --with-expecter
type Service interface {
Encrypt(ctx context.Context, data []byte) ([]byte, error)
Decrypt(ctx context.Context, data []byte) ([]byte, error)
Encrypt(ctx context.Context, namespace, name string, data string) (string, error)
Decrypt(ctx context.Context, namespace string, name string) ([]byte, error)
}
var _ Service = (*singleTenant)(nil)
var _ Service = (*secretsService)(nil)
type singleTenant struct {
inner secrets.Service
//go:generate mockery --name DecryptService --structname MockDecryptService --srcpkg=github.com/grafana/grafana/pkg/registry/apis/secret/service --filename decrypt_service_mock.go --with-expecter
type secretsService struct {
secretsSvc SecureValueService
decryptSvc grafanasecrets.DecryptService
}
func NewSingleTenant(svc secrets.Service) *singleTenant {
return &singleTenant{svc}
func NewSecretsService(secretsSvc SecureValueService, decryptSvc grafanasecrets.DecryptService) Service {
return &secretsService{
secretsSvc: secretsSvc,
decryptSvc: decryptSvc,
}
}
func (s *singleTenant) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
return s.inner.Encrypt(ctx, data, secrets.WithoutScope())
func (s *secretsService) Encrypt(ctx context.Context, namespace, name string, data string) (string, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return "", err
}
val := secretv1beta1.NewExposedSecureValue(data)
secret := &secretv1beta1.SecureValue{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: name,
},
Spec: secretv1beta1.SecureValueSpec{
Description: "provisioning: " + name,
Value: &val,
Decrypters: []string{svcName},
},
}
existing, err := s.secretsSvc.Read(ctx, xkube.Namespace(namespace), name)
if err != nil && !errors.Is(err, contracts.ErrSecureValueNotFound) {
return "", err
}
if existing != nil {
existing.Spec.Value = &val
existing, _, err = s.secretsSvc.Update(ctx, existing, user.GetUID())
if err != nil {
return "", err
}
return existing.GetName(), nil
}
finalSecret, err := s.secretsSvc.Create(ctx, secret, user.GetUID())
if err != nil {
return "", err
}
return finalSecret.GetName(), nil
}
func (s *singleTenant) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
return s.inner.Decrypt(ctx, data)
func (s *secretsService) Decrypt(ctx context.Context, namespace string, name string) ([]byte, error) {
ns, err := types.ParseNamespace(namespace)
if err != nil {
return nil, err
}
ctx = identity.WithServiceIdentityContext(ctx, ns.OrgID, identity.WithServiceIdentityName(svcName))
results, err := s.decryptSvc.Decrypt(ctx, namespace, name)
if err != nil {
return nil, err
}
if res, ok := results[name]; ok {
if res.Error() == nil {
return []byte(res.Value().DangerouslyExposeAndConsumeValue()), nil
}
return nil, res.Error()
}
return nil, contracts.ErrDecryptNotFound
}

@ -1,4 +1,4 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Code generated by mockery v2.52.4. DO NOT EDIT.
package secrets
@ -21,9 +21,9 @@ func (_m *MockService) EXPECT() *MockService_Expecter {
return &MockService_Expecter{mock: &_m.Mock}
}
// Decrypt provides a mock function with given fields: ctx, data
func (_m *MockService) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
ret := _m.Called(ctx, data)
// Decrypt provides a mock function with given fields: ctx, namespace, name
func (_m *MockService) Decrypt(ctx context.Context, namespace string, name string) ([]byte, error) {
ret := _m.Called(ctx, namespace, name)
if len(ret) == 0 {
panic("no return value specified for Decrypt")
@ -31,19 +31,19 @@ func (_m *MockService) Decrypt(ctx context.Context, data []byte) ([]byte, error)
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
return rf(ctx, data)
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]byte, error)); ok {
return rf(ctx, namespace, name)
}
if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
r0 = rf(ctx, data)
if rf, ok := ret.Get(0).(func(context.Context, string, string) []byte); ok {
r0 = rf(ctx, namespace, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
r1 = rf(ctx, data)
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, namespace, name)
} else {
r1 = ret.Error(1)
}
@ -58,14 +58,15 @@ type MockService_Decrypt_Call struct {
// Decrypt is a helper method to define mock.On call
// - ctx context.Context
// - data []byte
func (_e *MockService_Expecter) Decrypt(ctx interface{}, data interface{}) *MockService_Decrypt_Call {
return &MockService_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, data)}
// - namespace string
// - name string
func (_e *MockService_Expecter) Decrypt(ctx interface{}, namespace interface{}, name interface{}) *MockService_Decrypt_Call {
return &MockService_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, namespace, name)}
}
func (_c *MockService_Decrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockService_Decrypt_Call {
func (_c *MockService_Decrypt_Call) Run(run func(ctx context.Context, namespace string, name string)) *MockService_Decrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]byte))
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
@ -75,34 +76,32 @@ func (_c *MockService_Decrypt_Call) Return(_a0 []byte, _a1 error) *MockService_D
return _c
}
func (_c *MockService_Decrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockService_Decrypt_Call {
func (_c *MockService_Decrypt_Call) RunAndReturn(run func(context.Context, string, string) ([]byte, error)) *MockService_Decrypt_Call {
_c.Call.Return(run)
return _c
}
// Encrypt provides a mock function with given fields: ctx, data
func (_m *MockService) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
ret := _m.Called(ctx, data)
// Encrypt provides a mock function with given fields: ctx, namespace, name, data
func (_m *MockService) Encrypt(ctx context.Context, namespace string, name string, data string) (string, error) {
ret := _m.Called(ctx, namespace, name, data)
if len(ret) == 0 {
panic("no return value specified for Encrypt")
}
var r0 []byte
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
return rf(ctx, data)
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (string, error)); ok {
return rf(ctx, namespace, name, data)
}
if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
r0 = rf(ctx, data)
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) string); ok {
r0 = rf(ctx, namespace, name, data)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
r1 = rf(ctx, data)
if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
r1 = rf(ctx, namespace, name, data)
} else {
r1 = ret.Error(1)
}
@ -117,24 +116,26 @@ type MockService_Encrypt_Call struct {
// Encrypt is a helper method to define mock.On call
// - ctx context.Context
// - data []byte
func (_e *MockService_Expecter) Encrypt(ctx interface{}, data interface{}) *MockService_Encrypt_Call {
return &MockService_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, data)}
// - namespace string
// - name string
// - data string
func (_e *MockService_Expecter) Encrypt(ctx interface{}, namespace interface{}, name interface{}, data interface{}) *MockService_Encrypt_Call {
return &MockService_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, namespace, name, data)}
}
func (_c *MockService_Encrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockService_Encrypt_Call {
func (_c *MockService_Encrypt_Call) Run(run func(ctx context.Context, namespace string, name string, data string)) *MockService_Encrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]byte))
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockService_Encrypt_Call) Return(_a0 []byte, _a1 error) *MockService_Encrypt_Call {
func (_c *MockService_Encrypt_Call) Return(_a0 string, _a1 error) *MockService_Encrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockService_Encrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockService_Encrypt_Call {
func (_c *MockService_Encrypt_Call) RunAndReturn(run func(context.Context, string, string, string) (string, error)) *MockService_Encrypt_Call {
_c.Call.Return(run)
return _c
}

@ -0,0 +1,412 @@
package secrets
import (
"context"
"errors"
"testing"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets/mocks"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/service"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestNewSecretsService(t *testing.T) {
mockSecretsSvc := NewMockSecureValueService(t)
mockDecryptSvc := &mocks.MockDecryptService{}
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
assert.NotNil(t, svc)
assert.IsType(t, &secretsService{}, svc)
}
//nolint:gocyclo // This test is complex but it's a good test for the SecretsService.
func TestSecretsService_Encrypt(t *testing.T) {
tests := []struct {
name string
namespace string
secretName string
data string
setupMocks func(*MockSecureValueService, *mocks.MockDecryptService)
expectedName string
expectedError string
}{
{
name: "successfully create new secret",
namespace: "test-namespace",
secretName: "test-secret",
data: "secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
// Assert Read call with correct parameters
mockSecretsSvc.EXPECT().Read(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
xkube.Namespace("test-namespace"),
"test-secret",
).Return(nil, contracts.ErrSecureValueNotFound)
// Assert Create call with detailed validation
mockSecretsSvc.EXPECT().Create(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
mock.MatchedBy(func(sv *secretv1beta1.SecureValue) bool {
if sv.Namespace != "test-namespace" || sv.Name != "test-secret" {
return false
}
if sv.Spec.Description != "provisioning: test-secret" {
return false
}
if sv.Spec.Value == nil {
return false
}
if len(sv.Spec.Decrypters) != 1 || sv.Spec.Decrypters[0] != svcName {
return false
}
// Verify the actual secret value
if sv.Spec.Value.DangerouslyExposeAndConsumeValue() != "secret-data" {
return false
}
return true
}),
":test-uid",
).Return(&secretv1beta1.SecureValue{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "test-namespace",
},
}, nil)
},
expectedName: "test-secret",
},
{
name: "successfully update existing secret",
namespace: "test-namespace",
secretName: "existing-secret",
data: "new-secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
existingSecret := &secretv1beta1.SecureValue{
ObjectMeta: metav1.ObjectMeta{
Name: "existing-secret",
Namespace: "test-namespace",
},
Spec: secretv1beta1.SecureValueSpec{
Description: "provisioning: existing-secret",
Decrypters: []string{svcName},
},
}
// Assert Read call with context validation
mockSecretsSvc.EXPECT().Read(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
xkube.Namespace("test-namespace"),
"existing-secret",
).Return(existingSecret, nil)
// Assert Update call with detailed validation
mockSecretsSvc.EXPECT().Update(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
mock.MatchedBy(func(sv *secretv1beta1.SecureValue) bool {
if sv.Namespace != "test-namespace" || sv.Name != "existing-secret" {
return false
}
if sv.Spec.Value == nil {
return false
}
// Verify the updated secret value
if sv.Spec.Value.DangerouslyExposeAndConsumeValue() != "new-secret-data" {
return false
}
return true
}),
":test-uid",
).Return(&secretv1beta1.SecureValue{
ObjectMeta: metav1.ObjectMeta{
Name: "existing-secret",
Namespace: "test-namespace",
},
}, true, nil)
},
expectedName: "existing-secret",
},
{
name: "error reading existing secret",
namespace: "test-namespace",
secretName: "test-secret",
data: "secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
mockSecretsSvc.EXPECT().Read(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
xkube.Namespace("test-namespace"),
"test-secret",
).Return(nil, errors.New("database error"))
},
expectedError: "database error",
},
{
name: "error creating new secret",
namespace: "test-namespace",
secretName: "test-secret",
data: "secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
mockSecretsSvc.EXPECT().Read(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
xkube.Namespace("test-namespace"),
"test-secret",
).Return(nil, contracts.ErrSecureValueNotFound)
mockSecretsSvc.EXPECT().Create(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
mock.MatchedBy(func(sv *secretv1beta1.SecureValue) bool {
return sv.Namespace == "test-namespace" &&
sv.Name == "test-secret" &&
sv.Spec.Value != nil &&
sv.Spec.Value.DangerouslyExposeAndConsumeValue() == "secret-data"
}),
":test-uid",
).Return(nil, errors.New("creation failed"))
},
expectedError: "creation failed",
},
{
name: "error updating existing secret",
namespace: "test-namespace",
secretName: "existing-secret",
data: "new-secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
existingSecret := &secretv1beta1.SecureValue{
ObjectMeta: metav1.ObjectMeta{
Name: "existing-secret",
Namespace: "test-namespace",
},
Spec: secretv1beta1.SecureValueSpec{
Description: "provisioning: existing-secret",
Decrypters: []string{svcName},
},
}
mockSecretsSvc.EXPECT().Read(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
xkube.Namespace("test-namespace"),
"existing-secret",
).Return(existingSecret, nil)
mockSecretsSvc.EXPECT().Update(
mock.MatchedBy(func(ctx context.Context) bool {
requester, err := identity.GetRequester(ctx)
return err == nil && requester != nil && requester.GetUID() == ":test-uid"
}),
mock.MatchedBy(func(sv *secretv1beta1.SecureValue) bool {
return sv.Namespace == "test-namespace" &&
sv.Name == "existing-secret" &&
sv.Spec.Value != nil &&
sv.Spec.Value.DangerouslyExposeAndConsumeValue() == "new-secret-data"
}),
":test-uid",
).Return(nil, false, errors.New("update failed"))
},
expectedError: "update failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecretsSvc := NewMockSecureValueService(t)
mockDecryptSvc := &mocks.MockDecryptService{}
tt.setupMocks(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
ctx := context.Background()
ctx = identity.WithRequester(ctx, &identity.StaticRequester{
UserUID: "test-uid",
})
result, err := svc.Encrypt(ctx, tt.namespace, tt.secretName, tt.data)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedName, result)
}
})
}
}
func TestSecretsService_Encrypt_NoIdentity(t *testing.T) {
mockSecretsSvc := NewMockSecureValueService(t)
mockDecryptSvc := &mocks.MockDecryptService{}
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
ctx := context.Background()
result, err := svc.Encrypt(ctx, "test-namespace", "test-secret", "secret-data")
assert.Error(t, err)
assert.Empty(t, result)
}
func TestSecretsService_Decrypt(t *testing.T) {
tests := []struct {
name string
namespace string
secretName string
setupMocks func(*MockSecureValueService, *mocks.MockDecryptService)
expectedResult []byte
expectedError string
}{
{
name: "successfully decrypt secret",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
exposedValue := secretv1beta1.NewExposedSecureValue("decrypted-data")
mockResult := service.NewDecryptResultValue(&exposedValue)
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
// Verify that the context is not nil (the service creates a new StaticRequester)
return ctx != nil
}),
"test-namespace",
"test-secret",
).Return(map[string]service.DecryptResult{
"test-secret": mockResult,
}, nil)
},
expectedResult: []byte("decrypted-data"),
},
{
name: "decrypt service error",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
return ctx != nil
}),
"test-namespace",
"test-secret",
).Return(nil, errors.New("decrypt service error"))
},
expectedError: "decrypt service error",
},
{
name: "secret not found in results",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
return ctx != nil
}),
"test-namespace",
"test-secret",
).Return(map[string]service.DecryptResult{}, nil)
},
expectedError: contracts.ErrDecryptNotFound.Error(),
},
{
name: "decrypt result has error",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
mockResult := service.NewDecryptResultErr(errors.New("decryption failed"))
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
return ctx != nil
}),
"test-namespace",
"test-secret",
).Return(map[string]service.DecryptResult{
"test-secret": mockResult,
}, nil)
},
expectedError: "decryption failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecretsSvc := NewMockSecureValueService(t)
mockDecryptSvc := &mocks.MockDecryptService{}
tt.setupMocks(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
ctx := context.Background()
result, err := svc.Decrypt(ctx, tt.namespace, tt.secretName)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, result)
}
})
}
}
// Test to verify that the Decrypt method creates the correct StaticRequester
func TestSecretsService_Decrypt_StaticRequesterCreation(t *testing.T) {
mockSecretsSvc := NewMockSecureValueService(t)
mockDecryptSvc := &mocks.MockDecryptService{}
exposedValue := secretv1beta1.NewExposedSecureValue("test-data")
mockResult := service.NewDecryptResultValue(&exposedValue)
// Create a more detailed context matcher to verify the StaticRequester is created correctly
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
// At minimum, verify the context is not nil and is different from the original
return ctx != nil
}),
"test-namespace",
"test-secret",
).Return(map[string]service.DecryptResult{
"test-secret": mockResult,
}, nil)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
ctx := context.Background()
result, err := svc.Decrypt(ctx, "test-namespace", "test-secret")
assert.NoError(t, err)
assert.Equal(t, []byte("test-data"), result)
}

@ -0,0 +1,226 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package secrets
import (
context "context"
v1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
mock "github.com/stretchr/testify/mock"
xkube "github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
)
// MockSecureValueService is an autogenerated mock type for the SecureValueService type
type MockSecureValueService struct {
mock.Mock
}
type MockSecureValueService_Expecter struct {
mock *mock.Mock
}
func (_m *MockSecureValueService) EXPECT() *MockSecureValueService_Expecter {
return &MockSecureValueService_Expecter{mock: &_m.Mock}
}
// Create provides a mock function with given fields: ctx, sv, actorUID
func (_m *MockSecureValueService) Create(ctx context.Context, sv *v1beta1.SecureValue, actorUID string) (*v1beta1.SecureValue, error) {
ret := _m.Called(ctx, sv, actorUID)
if len(ret) == 0 {
panic("no return value specified for Create")
}
var r0 *v1beta1.SecureValue
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.SecureValue, string) (*v1beta1.SecureValue, error)); ok {
return rf(ctx, sv, actorUID)
}
if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.SecureValue, string) *v1beta1.SecureValue); ok {
r0 = rf(ctx, sv, actorUID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v1beta1.SecureValue)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v1beta1.SecureValue, string) error); ok {
r1 = rf(ctx, sv, actorUID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSecureValueService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
type MockSecureValueService_Create_Call struct {
*mock.Call
}
// Create is a helper method to define mock.On call
// - ctx context.Context
// - sv *v1beta1.SecureValue
// - actorUID string
func (_e *MockSecureValueService_Expecter) Create(ctx interface{}, sv interface{}, actorUID interface{}) *MockSecureValueService_Create_Call {
return &MockSecureValueService_Create_Call{Call: _e.mock.On("Create", ctx, sv, actorUID)}
}
func (_c *MockSecureValueService_Create_Call) Run(run func(ctx context.Context, sv *v1beta1.SecureValue, actorUID string)) *MockSecureValueService_Create_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v1beta1.SecureValue), args[2].(string))
})
return _c
}
func (_c *MockSecureValueService_Create_Call) Return(_a0 *v1beta1.SecureValue, _a1 error) *MockSecureValueService_Create_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSecureValueService_Create_Call) RunAndReturn(run func(context.Context, *v1beta1.SecureValue, string) (*v1beta1.SecureValue, error)) *MockSecureValueService_Create_Call {
_c.Call.Return(run)
return _c
}
// Read provides a mock function with given fields: ctx, namespace, name
func (_m *MockSecureValueService) Read(ctx context.Context, namespace xkube.Namespace, name string) (*v1beta1.SecureValue, error) {
ret := _m.Called(ctx, namespace, name)
if len(ret) == 0 {
panic("no return value specified for Read")
}
var r0 *v1beta1.SecureValue
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, xkube.Namespace, string) (*v1beta1.SecureValue, error)); ok {
return rf(ctx, namespace, name)
}
if rf, ok := ret.Get(0).(func(context.Context, xkube.Namespace, string) *v1beta1.SecureValue); ok {
r0 = rf(ctx, namespace, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v1beta1.SecureValue)
}
}
if rf, ok := ret.Get(1).(func(context.Context, xkube.Namespace, string) error); ok {
r1 = rf(ctx, namespace, name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSecureValueService_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
type MockSecureValueService_Read_Call struct {
*mock.Call
}
// Read is a helper method to define mock.On call
// - ctx context.Context
// - namespace xkube.Namespace
// - name string
func (_e *MockSecureValueService_Expecter) Read(ctx interface{}, namespace interface{}, name interface{}) *MockSecureValueService_Read_Call {
return &MockSecureValueService_Read_Call{Call: _e.mock.On("Read", ctx, namespace, name)}
}
func (_c *MockSecureValueService_Read_Call) Run(run func(ctx context.Context, namespace xkube.Namespace, name string)) *MockSecureValueService_Read_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(xkube.Namespace), args[2].(string))
})
return _c
}
func (_c *MockSecureValueService_Read_Call) Return(_a0 *v1beta1.SecureValue, _a1 error) *MockSecureValueService_Read_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSecureValueService_Read_Call) RunAndReturn(run func(context.Context, xkube.Namespace, string) (*v1beta1.SecureValue, error)) *MockSecureValueService_Read_Call {
_c.Call.Return(run)
return _c
}
// Update provides a mock function with given fields: ctx, newSecureValue, actorUID
func (_m *MockSecureValueService) Update(ctx context.Context, newSecureValue *v1beta1.SecureValue, actorUID string) (*v1beta1.SecureValue, bool, error) {
ret := _m.Called(ctx, newSecureValue, actorUID)
if len(ret) == 0 {
panic("no return value specified for Update")
}
var r0 *v1beta1.SecureValue
var r1 bool
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.SecureValue, string) (*v1beta1.SecureValue, bool, error)); ok {
return rf(ctx, newSecureValue, actorUID)
}
if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.SecureValue, string) *v1beta1.SecureValue); ok {
r0 = rf(ctx, newSecureValue, actorUID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*v1beta1.SecureValue)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v1beta1.SecureValue, string) bool); ok {
r1 = rf(ctx, newSecureValue, actorUID)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(context.Context, *v1beta1.SecureValue, string) error); ok {
r2 = rf(ctx, newSecureValue, actorUID)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// MockSecureValueService_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
type MockSecureValueService_Update_Call struct {
*mock.Call
}
// Update is a helper method to define mock.On call
// - ctx context.Context
// - newSecureValue *v1beta1.SecureValue
// - actorUID string
func (_e *MockSecureValueService_Expecter) Update(ctx interface{}, newSecureValue interface{}, actorUID interface{}) *MockSecureValueService_Update_Call {
return &MockSecureValueService_Update_Call{Call: _e.mock.On("Update", ctx, newSecureValue, actorUID)}
}
func (_c *MockSecureValueService_Update_Call) Run(run func(ctx context.Context, newSecureValue *v1beta1.SecureValue, actorUID string)) *MockSecureValueService_Update_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v1beta1.SecureValue), args[2].(string))
})
return _c
}
func (_c *MockSecureValueService_Update_Call) Return(_a0 *v1beta1.SecureValue, _a1 bool, _a2 error) *MockSecureValueService_Update_Call {
_c.Call.Return(_a0, _a1, _a2)
return _c
}
func (_c *MockSecureValueService_Update_Call) RunAndReturn(run func(context.Context, *v1beta1.SecureValue, string) (*v1beta1.SecureValue, bool, error)) *MockSecureValueService_Update_Call {
_c.Call.Return(run)
return _c
}
// NewMockSecureValueService creates a new instance of MockSecureValueService. 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 NewMockSecureValueService(t interface {
mock.TestingT
Cleanup(func())
}) *MockSecureValueService {
mock := &MockSecureValueService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest"
"github.com/grafana/grafana/pkg/registry/apis/secret/service"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/rendering"
@ -37,8 +38,9 @@ type WebhookExtraBuilder struct {
func ProvideWebhooks(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
// FIXME: use multi-tenant service when one exists. In this state, we can't make this a multi-tenant service!
secretsSvc grafanasecrets.Service,
legacySecretsSvc grafanasecrets.Service,
secretsSvc *service.SecureValueService,
decryptSvc service.DecryptService,
ghFactory *github.Factory,
renderer rendering.Service,
blobstore resource.ResourceClient,
@ -65,13 +67,13 @@ func ProvideWebhooks(
evaluator := pullrequest.NewEvaluator(screenshotRenderer, parsers, urlProvider)
commenter := pullrequest.NewCommenter()
pullRequestWorker := pullrequest.NewPullRequestWorker(evaluator, commenter)
repositorySecrets := secrets.NewRepositorySecrets(features, secrets.NewSecretsService(secretsSvc, decryptSvc), secrets.NewSingleTenant(legacySecretsSvc))
return NewWebhookExtra(
features,
render,
webhook,
urlProvider,
secrets.NewSingleTenant(secretsSvc),
repositorySecrets,
ghFactory,
filepath.Join(cfg.DataPath, "clone"),
parsers,
@ -84,11 +86,10 @@ func ProvideWebhooks(
// WebhookExtra implements the Extra interface for webhooks
// to wrap around
type WebhookExtra struct {
features featuremgmt.FeatureToggles
render *renderConnector
webhook *webhookConnector
urlProvider func(namespace string) string
secrets secrets.Service
secrets secrets.RepositorySecrets
ghFactory *github.Factory
clonedir string
parsers resources.ParserFactory
@ -96,18 +97,16 @@ type WebhookExtra struct {
}
func NewWebhookExtra(
features featuremgmt.FeatureToggles,
render *renderConnector,
webhook *webhookConnector,
urlProvider func(namespace string) string,
secrets secrets.Service,
secrets secrets.RepositorySecrets,
ghFactory *github.Factory,
clonedir string,
parsers resources.ParserFactory,
workers []jobs.Worker,
) *WebhookExtra {
return &WebhookExtra{
features: features,
render: render,
webhook: webhook,
urlProvider: urlProvider,
@ -133,11 +132,13 @@ func (e *WebhookExtra) Authorize(ctx context.Context, a authorizer.Attributes) (
func (e *WebhookExtra) Mutate(ctx context.Context, r *provisioning.Repository) error {
// Encrypt webhook secret if present
if r.Status.Webhook != nil && r.Status.Webhook.Secret != "" {
encryptedSecret, err := e.secrets.Encrypt(ctx, []byte(r.Status.Webhook.Secret))
secretName := r.GetName() + "-webhook-secret"
nameOrValue, err := e.secrets.Encrypt(ctx, r, secretName, r.Status.Webhook.Secret)
if err != nil {
return fmt.Errorf("failed to encrypt webhook secret: %w", err)
}
r.Status.Webhook.EncryptedSecret = encryptedSecret
r.Status.Webhook.EncryptedSecret = nameOrValue
r.Status.Webhook.Secret = ""
}
@ -190,7 +191,7 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit
// Decrypt GitHub token if needed
ghToken := ghCfg.Token
if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
decrypted, err := e.secrets.Decrypt(ctx, ghCfg.EncryptedToken)
decrypted, err := e.secrets.Decrypt(ctx, r, string(ghCfg.EncryptedToken))
if err != nil {
return nil, fmt.Errorf("decrypt github token: %w", err)
}

@ -36,7 +36,7 @@ type githubWebhookRepository struct {
config *provisioning.Repository
owner string
repo string
secrets secrets.Service
secrets secrets.RepositorySecrets
gh pgh.Client
webhookURL string
}
@ -44,7 +44,7 @@ type githubWebhookRepository struct {
func NewGithubWebhookRepository(
basic pgh.GithubRepository,
webhookURL string,
secrets secrets.Service,
secrets secrets.RepositorySecrets,
) GithubWebhookRepository {
return &githubWebhookRepository{
GithubRepository: basic,
@ -63,7 +63,7 @@ func (r *githubWebhookRepository) Webhook(ctx context.Context, req *http.Request
return nil, fmt.Errorf("unexpected webhook request")
}
secret, err := r.secrets.Decrypt(ctx, r.config.Status.Webhook.EncryptedSecret)
secret, err := r.secrets.Decrypt(ctx, r.config, string(r.config.Status.Webhook.EncryptedSecret))
if err != nil {
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
}

@ -116,7 +116,7 @@ func TestGitHubRepository_Webhook(t *testing.T) {
config *provisioning.Repository
webhookSecret string
setupRequest func() *http.Request
mockSetup func(t *testing.T, mockSecrets *secrets.MockService)
mockSetup func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets)
expected *provisioning.WebhookResponse
expectedError error
}{
@ -156,8 +156,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
req, _ := http.NewRequest("POST", "/webhook", nil)
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return(nil, errors.New("decryption failed"))
},
expectedError: fmt.Errorf("failed to decrypt secret: decryption failed"),
@ -183,8 +183,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: apierrors.NewUnauthorized("invalid signature"),
@ -218,8 +218,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -264,8 +264,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -312,8 +312,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -358,8 +358,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("missing repository in push event"),
@ -398,8 +398,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("repository mismatch"),
@ -441,8 +441,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -497,8 +497,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -564,8 +564,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -628,8 +628,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -682,8 +682,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -733,8 +733,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("missing repository in pull request event"),
@ -782,8 +782,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("missing GitHub config"),
@ -833,8 +833,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("repository mismatch"),
@ -873,12 +873,136 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("expected PR in event"),
},
{
name: "secret decryption error with new secrets store",
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("test-secret"),
},
},
},
setupRequest: func() *http.Request {
req, _ := http.NewRequest("POST", "/webhook", nil)
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "test-secret").
Return(nil, errors.New("decryption failed"))
},
expectedError: fmt.Errorf("failed to decrypt secret: decryption failed"),
},
{
name: "ping event with new secrets store",
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("test-secret"),
},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{}`
req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload))
req.Header.Set("X-GitHub-Event", "ping")
req.Header.Set("Content-Type", "application/json")
// Create a valid signature
mac := hmac.New(sha256.New, []byte("webhook-secret"))
mac.Write([]byte(payload))
signature := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Hub-Signature-256", "sha256="+signature)
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "test-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusOK,
Message: "ping received",
},
},
{
name: "push event for main branch with new secrets store",
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
Sync: provisioning.SyncOptions{
Enabled: true,
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("test-secret"),
},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"ref": "refs/heads/main",
"repository": {
"full_name": "grafana/grafana"
}
}`
req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload))
req.Header.Set("X-GitHub-Event", "push")
req.Header.Set("Content-Type", "application/json")
// Create a valid signature
mac := hmac.New(sha256.New, []byte("webhook-secret"))
mac.Write([]byte(payload))
signature := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Hub-Signature-256", "sha256="+signature)
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "test-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusAccepted,
Job: &provisioning.JobSpec{
Repository: "test-repo",
Action: provisioning.JobActionPull,
Pull: &provisioning.SyncJobOptions{
Incremental: true,
},
},
},
},
{
name: "unsupported event type",
config: &provisioning.Repository{
@ -908,8 +1032,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockService) {
mockSecrets.EXPECT().Decrypt(mock.Anything, []byte("encrypted-secret")).
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
@ -922,7 +1046,7 @@ func TestGitHubRepository_Webhook(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock secrets service
mockSecrets := secrets.NewMockService(t)
mockSecrets := secrets.NewMockRepositorySecrets(t)
// Set up the mock expectations
if tt.mockSetup != nil {

@ -14,7 +14,7 @@ type OSSDecryptService struct {
var _ service.DecryptService = &OSSDecryptService{}
func ProvideDecryptService(decryptStore contracts.DecryptStorage) *OSSDecryptService {
func ProvideDecryptService(decryptStore contracts.DecryptStorage) service.DecryptService {
return &OSSDecryptService{
decryptStore: decryptStore,
}

@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage"
"github.com/grafana/grafana/pkg/registry/apis/ofrep"
"github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks"
"github.com/grafana/grafana/pkg/registry/apis/query"
"github.com/grafana/grafana/pkg/registry/apis/secret"
@ -57,6 +58,7 @@ var WireSet = wire.NewSet(
folders.RegisterAPIService,
iam.RegisterAPIService,
ProvisioningExtras,
secrets.ProvideRepositorySecrets,
provisioning.RegisterAPIService,
service.RegisterAPIService,
query.RegisterAPIService,

@ -5,6 +5,7 @@ import (
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/grpcserver"
@ -33,6 +34,7 @@ func ProvideTestEnv(
resourceClient resource.ResourceClient,
idService auth.IDService,
githubFactory *github.Factory,
repositorySecrets secrets.RepositorySecrets,
) (*TestEnv, error) {
return &TestEnv{
TestingT: testingT,
@ -48,6 +50,7 @@ func ProvideTestEnv(
ResourceClient: resourceClient,
IDService: idService,
GitHubFactory: githubFactory,
RepositorySecrets: repositorySecrets,
}, nil
}
@ -69,4 +72,5 @@ type TestEnv struct {
ResourceClient resource.ResourceClient
IDService auth.IDService
GitHubFactory *github.Factory
RepositorySecrets secrets.RepositorySecrets
}

@ -427,6 +427,7 @@ var wireBasicSet = wire.NewSet(
secretmetadata.ProvideDecryptStorage,
secretdecrypt.ProvideDecryptAuthorizer,
secretdecrypt.ProvideDecryptAllowList,
secretdecrypt.ProvideDecryptService,
secretencryption.ProvideDataKeyStorage,
secretencryption.ProvideEncryptedValueStorage,
secretsecurevalueservice.ProvideSecureValueService,
@ -502,7 +503,8 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
func InitializeForTest(t sqlutil.ITestDB, testingT interface {
mock.TestingT
Cleanup(func())
}, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) {
}, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions,
) (*TestEnv, error) {
wire.Build(wireExtsTestSet)
return &TestEnv{Server: &Server{}, TestingT: testingT, SQLStore: &sqlstore.SQLStore{}, Cfg: &setting.Cfg{}}, nil
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,177 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package featuremgmt
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockFeatureToggles is an autogenerated mock type for the FeatureToggles type
type MockFeatureToggles struct {
mock.Mock
}
type MockFeatureToggles_Expecter struct {
mock *mock.Mock
}
func (_m *MockFeatureToggles) EXPECT() *MockFeatureToggles_Expecter {
return &MockFeatureToggles_Expecter{mock: &_m.Mock}
}
// GetEnabled provides a mock function with given fields: ctx
func (_m *MockFeatureToggles) GetEnabled(ctx context.Context) map[string]bool {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for GetEnabled")
}
var r0 map[string]bool
if rf, ok := ret.Get(0).(func(context.Context) map[string]bool); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]bool)
}
}
return r0
}
// MockFeatureToggles_GetEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetEnabled'
type MockFeatureToggles_GetEnabled_Call struct {
*mock.Call
}
// GetEnabled is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockFeatureToggles_Expecter) GetEnabled(ctx interface{}) *MockFeatureToggles_GetEnabled_Call {
return &MockFeatureToggles_GetEnabled_Call{Call: _e.mock.On("GetEnabled", ctx)}
}
func (_c *MockFeatureToggles_GetEnabled_Call) Run(run func(ctx context.Context)) *MockFeatureToggles_GetEnabled_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockFeatureToggles_GetEnabled_Call) Return(_a0 map[string]bool) *MockFeatureToggles_GetEnabled_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockFeatureToggles_GetEnabled_Call) RunAndReturn(run func(context.Context) map[string]bool) *MockFeatureToggles_GetEnabled_Call {
_c.Call.Return(run)
return _c
}
// IsEnabled provides a mock function with given fields: ctx, flag
func (_m *MockFeatureToggles) IsEnabled(ctx context.Context, flag string) bool {
ret := _m.Called(ctx, flag)
if len(ret) == 0 {
panic("no return value specified for IsEnabled")
}
var r0 bool
if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok {
r0 = rf(ctx, flag)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockFeatureToggles_IsEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsEnabled'
type MockFeatureToggles_IsEnabled_Call struct {
*mock.Call
}
// IsEnabled is a helper method to define mock.On call
// - ctx context.Context
// - flag string
func (_e *MockFeatureToggles_Expecter) IsEnabled(ctx interface{}, flag interface{}) *MockFeatureToggles_IsEnabled_Call {
return &MockFeatureToggles_IsEnabled_Call{Call: _e.mock.On("IsEnabled", ctx, flag)}
}
func (_c *MockFeatureToggles_IsEnabled_Call) Run(run func(ctx context.Context, flag string)) *MockFeatureToggles_IsEnabled_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockFeatureToggles_IsEnabled_Call) Return(_a0 bool) *MockFeatureToggles_IsEnabled_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockFeatureToggles_IsEnabled_Call) RunAndReturn(run func(context.Context, string) bool) *MockFeatureToggles_IsEnabled_Call {
_c.Call.Return(run)
return _c
}
// IsEnabledGlobally provides a mock function with given fields: flag
func (_m *MockFeatureToggles) IsEnabledGlobally(flag string) bool {
ret := _m.Called(flag)
if len(ret) == 0 {
panic("no return value specified for IsEnabledGlobally")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(flag)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockFeatureToggles_IsEnabledGlobally_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsEnabledGlobally'
type MockFeatureToggles_IsEnabledGlobally_Call struct {
*mock.Call
}
// IsEnabledGlobally is a helper method to define mock.On call
// - flag string
func (_e *MockFeatureToggles_Expecter) IsEnabledGlobally(flag interface{}) *MockFeatureToggles_IsEnabledGlobally_Call {
return &MockFeatureToggles_IsEnabledGlobally_Call{Call: _e.mock.On("IsEnabledGlobally", flag)}
}
func (_c *MockFeatureToggles_IsEnabledGlobally_Call) Run(run func(flag string)) *MockFeatureToggles_IsEnabledGlobally_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockFeatureToggles_IsEnabledGlobally_Call) Return(_a0 bool) *MockFeatureToggles_IsEnabledGlobally_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockFeatureToggles_IsEnabledGlobally_Call) RunAndReturn(run func(string) bool) *MockFeatureToggles_IsEnabledGlobally_Call {
_c.Call.Return(run)
return _c
}
// NewMockFeatureToggles creates a new instance of MockFeatureToggles. 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 NewMockFeatureToggles(t interface {
mock.TestingT
Cleanup(func())
}) *MockFeatureToggles {
mock := &MockFeatureToggles{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

@ -6,6 +6,7 @@ import (
"encoding/json"
)
//go:generate mockery --name FeatureToggles --structname MockFeatureToggles --inpackage --filename feature_toggles_mock.go --with-expecter
type FeatureToggles interface {
// IsEnabled checks if a feature is enabled for a given context.
// The settings may be per user, tenant, or globally set in the cloud

@ -337,6 +337,13 @@ var (
RequiresRestart: true,
Owner: grafanaAppPlatformSquad,
},
{
Name: "provisioningSecretsService",
Description: "Experimental feature to use the secrets service for provisioning instead of the legacy secrets",
Stage: FeatureStageExperimental,
RequiresRestart: true,
Owner: grafanaAppPlatformSquad,
},
{
Name: "grafanaAPIServerEnsureKubectlAccess",
Description: "Start an additional https handler and write kubectl options",

@ -43,6 +43,7 @@ mlExpressions,experimental,@grafana/alerting-squad,false,false,false
datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false
provisioning,experimental,@grafana/grafana-app-platform-squad,false,true,false
provisioningSecretsService,experimental,@grafana/grafana-app-platform-squad,false,true,false
grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,true,true,false
featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,true,false
awsAsyncQueryCaching,GA,@grafana/aws-datasources,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
43 datasourceAPIServers experimental @grafana/grafana-app-platform-squad false true false
44 grafanaAPIServerWithExperimentalAPIs experimental @grafana/grafana-app-platform-squad true true false
45 provisioning experimental @grafana/grafana-app-platform-squad false true false
46 provisioningSecretsService experimental @grafana/grafana-app-platform-squad false true false
47 grafanaAPIServerEnsureKubectlAccess experimental @grafana/grafana-app-platform-squad true true false
48 featureToggleAdminPage experimental @grafana/grafana-operator-experience-squad false true false
49 awsAsyncQueryCaching GA @grafana/aws-datasources false false false

@ -183,6 +183,10 @@ const (
// Next generation provisioning... and git
FlagProvisioning = "provisioning"
// FlagProvisioningSecretsService
// Experimental feature to use the secrets service for provisioning instead of the legacy secrets
FlagProvisioningSecretsService = "provisioningSecretsService"
// FlagGrafanaAPIServerEnsureKubectlAccess
// Start an additional https handler and write kubectl options
FlagGrafanaAPIServerEnsureKubectlAccess = "grafanaAPIServerEnsureKubectlAccess"

@ -2506,6 +2506,22 @@
"requiresRestart": true
}
},
{
"metadata": {
"name": "provisioningSecretsService",
"resourceVersion": "1752501981893",
"creationTimestamp": "2025-07-14T14:05:50Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-07-14 14:06:21.89322 +0000 UTC"
}
},
"spec": {
"description": "Experimental feature to use the secrets service for provisioning instead of the legacy secrets",
"stage": "experimental",
"codeowner": "@grafana/grafana-app-platform-squad",
"requiresRestart": true
}
},
{
"metadata": {
"name": "publicDashboardsEmailSharing",

@ -197,6 +197,13 @@ func withLogs(opts *testinfra.GrafanaOpts) {
opts.EnableLog = true
}
func useAppPlatformSecrets(opts *testinfra.GrafanaOpts) {
opts.EnableFeatureToggles = append(opts.EnableFeatureToggles,
featuremgmt.FlagProvisioningSecretsService,
featuremgmt.FlagSecretsManagementAppPlatform,
)
}
func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper {
provisioningPath := t.TempDir()
opts := testinfra.GrafanaOpts{

@ -0,0 +1,447 @@
package provisioning
import (
"context"
"encoding/base64"
"fmt"
"strings"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestIntegrationProvisioning_LegacySecrets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
type expectedField struct {
Path []string
ExpectedDecryptedValue string
}
secretsService := helper.GetEnv().RepositorySecrets
tests := []struct {
name string
values map[string]any
inputFile string
expectedFields []expectedField
}{
{
name: "github token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/github-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedDecryptedValue: "some-token",
},
},
},
{
name: "git token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/git-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedDecryptedValue: "some-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
// Move encrypted token mutation
for _, expectedField := range test.expectedFields {
value, decrypted := encryptedField(t, secretsService, nil, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
require.False(t, strings.HasPrefix(value, name), "value should not be prefixed with the repository name")
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
})
}
}
func TestIntegrationProvisioning_Secrets_LegacyUpdate(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
updateOptions := metav1.UpdateOptions{}
ctx := context.Background()
secretsService := helper.GetEnv().RepositorySecrets
type expectedField struct {
Path []string
ExpectedValue string
ExpectedDecryptedValue string
}
tests := []struct {
name string
values map[string]any
inputFile string
updateValues map[string]any
expectedFields []expectedField
}{
{
name: "update github token (legacy secrets)",
values: map[string]any{
"Token": "initial-token",
},
inputFile: "testdata/github-readonly.json.tmpl",
updateValues: map[string]any{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedDecryptedValue: "updated-token",
},
},
},
{
name: "update git token (legacy secrets)",
values: map[string]any{
"Token": "initial-token",
},
inputFile: "testdata/git-readonly.json.tmpl",
updateValues: map[string]any{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedDecryptedValue: "updated-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create initial resource
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
// Prepare updated resource
updatedInput := helper.RenderObject(t, test.inputFile, test.updateValues)
// Set the same name and resourceVersion for update
updatedInput.Object["metadata"].(map[string]any)["name"] = name
// Fetch current resourceVersion
current, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to get current resource for update")
updatedInput.Object["metadata"].(map[string]any)["resourceVersion"] =
current.Object["metadata"].(map[string]any)["resourceVersion"]
_, err = helper.Repositories.Resource.Update(ctx, updatedInput, updateOptions)
require.NoError(t, err, "failed to update resource")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource after update")
for _, expectedField := range test.expectedFields {
value, decrypted := encryptedField(t, secretsService, nil, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
require.False(t, strings.HasPrefix(value, name), "value should not be prefixed with the repository name")
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
})
}
}
func TestIntegrationProvisioning_Secrets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t, useAppPlatformSecrets)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
secretsService := helper.GetEnv().RepositorySecrets
type expectedField struct {
Path []string
ExpectedValue string
ExpectedDecryptedValue string
}
// TODO: Add test of fallbacks
tests := []struct {
name string
values map[string]any
inputFile string
expectedFields []expectedField
}{
{
name: "github token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/github-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedValue: "github-token",
ExpectedDecryptedValue: "some-token",
},
},
},
{
name: "git token encrypted",
values: map[string]any{
"Token": "some-token",
},
inputFile: "testdata/git-readonly.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedValue: "git-token",
ExpectedDecryptedValue: "some-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
repo := unstructuredToRepository(t, output)
// Move encrypted token mutation
for _, expectedField := range test.expectedFields {
value, decrypted := encryptedField(t, secretsService, repo, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
if expectedField.ExpectedValue != "" {
require.Equal(t, name+"-"+expectedField.ExpectedValue, value)
}
if expectedField.ExpectedDecryptedValue != "" {
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
}
})
}
}
func TestIntegrationProvisioning_Secrets_Update(t *testing.T) {
ctx := context.Background()
helper := runGrafana(t, useAppPlatformSecrets)
secretsService := helper.GetEnv().RepositorySecrets
createOptions := metav1.CreateOptions{}
updateOptions := metav1.UpdateOptions{}
type expectedField struct {
Path []string
ExpectedValue string
ExpectedDecryptedValue string
}
tests := []struct {
name string
inputFile string
values map[string]interface{}
updateValues map[string]interface{}
expectedFields []expectedField
updatedFields []expectedField
}{
{
name: "update encrypted git token",
inputFile: "testdata/git-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
updateValues: map[string]interface{}{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedValue: "git-token",
ExpectedDecryptedValue: "initial-token",
},
},
updatedFields: []expectedField{
{
Path: []string{"spec", "git", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "git", "encryptedToken"},
ExpectedValue: "git-token",
ExpectedDecryptedValue: "updated-token",
},
},
},
{
name: "update encrypted github token",
inputFile: "testdata/github-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
updateValues: map[string]interface{}{
"Token": "updated-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedValue: "github-token",
ExpectedDecryptedValue: "initial-token",
},
},
updatedFields: []expectedField{
{
Path: []string{"spec", "github", "token"},
ExpectedDecryptedValue: "",
},
{
Path: []string{"spec", "github", "encryptedToken"},
ExpectedValue: "github-token",
ExpectedDecryptedValue: "updated-token",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Create initial resource
input := helper.RenderObject(t, test.inputFile, test.values)
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
name := mustNestedString(input.Object, "metadata", "name")
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
// Update the resource
updatedInput := helper.RenderObject(t, test.inputFile, test.updateValues)
// Set the same name and resourceVersion for update
_ = unstructured.SetNestedField(updatedInput.Object, name, "metadata", "name")
_ = unstructured.SetNestedField(updatedInput.Object, output.GetResourceVersion(), "metadata", "resourceVersion")
_, err = helper.Repositories.Resource.Update(ctx, updatedInput, updateOptions)
require.NoError(t, err, "failed to update resource")
updatedOutput, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
require.NoError(t, err, "failed to read back updated resource")
updatedRepo := unstructuredToRepository(t, updatedOutput)
// Check updated fields
for _, expectedField := range test.updatedFields {
value, decrypted := encryptedField(t, secretsService, updatedRepo, updatedOutput.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
if expectedField.ExpectedValue != "" {
require.Equal(t, name+"-"+expectedField.ExpectedValue, value)
}
if expectedField.ExpectedDecryptedValue != "" {
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
}
}
})
}
}
func encryptedField(t *testing.T, secretsService secrets.RepositorySecrets, repo *provisioning.Repository, obj map[string]any, path []string, expectedValue bool) (string, string) {
value, found, err := base64DecodedField(obj, path)
if err != nil {
require.NoError(t, err, "failed to decode base64 value")
}
if expectedValue {
decrypted, err := secretsService.Decrypt(context.Background(), repo, value)
require.NoError(t, err, "failed to eecrypt value")
return value, string(decrypted)
} else {
require.False(t, found, "value should not be found")
return "", ""
}
}
func base64DecodedField(obj map[string]any, path []string) (string, bool, error) {
value, found, err := unstructured.NestedFieldNoCopy(obj, path...)
if err != nil {
return "", false, err
}
if !found {
return "", false, nil
}
valueStr, ok := value.(string)
if !ok {
return "", false, fmt.Errorf("value is not a string")
}
decodedValue, err := base64.StdEncoding.DecodeString(valueStr)
if err != nil {
return "", false, fmt.Errorf("failed to decode base64 valueStr: %w", err)
}
return string(decodedValue), true, nil
}

@ -0,0 +1,24 @@
{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": {
"name": "{{ or .Name "git-repository" }}"
},
"spec": {
"title": "{{ or .Title .Name "Git repository" }}",
"description": "{{ or .Description .Name "Load grafana dashboard from fake repository" }}",
"type": "git",
"git": {
"url": "{{ or .URL "https://github.com/grafana/grafana-git-sync-demo" }}",
"branch": "{{ or .Branch "integration-test" }}",
"token": "{{ or .Token "" }}",
"path": "{{ or .Path "grafana/" }}"
},
"sync": {
"enabled": {{ if .SyncEnabled }} true {{ else }} false {{ end }},
"target": "{{ or .SyncTarget "folder" }}",
"intervalSeconds": {{ or .SyncIntervalSeconds 60 }}
},
"workflows": []
}
}
Loading…
Cancel
Save