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:
co-authored by
Roberto Jimenez Sanchez
Roberto Jiménez Sánchez
parent
68b9a5f57c
commit
d39a47a89b
@@ -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
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
+126
-54
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
|
||||
|
||||
|
@@ -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": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user