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>
This commit is contained in:
Stephanie Hingtgen
2025-07-15 15:43:17 +02:00
committed by GitHub
co-authored by Roberto Jimenez Sanchez Roberto Jiménez Sánchez
parent 68b9a5f57c
commit d39a47a89b
30 changed files with 2591 additions and 194 deletions
@@ -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,
}
+31 -29
View File
@@ -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 {
+1 -1
View File
@@ -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,
}
+2
View File
@@ -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,