Provisioning: delete secrets on repository deletion (#108113)

- Add hooks to git, github and github webhooks to remove the. 
- Implement deletion in secrets package.
- Add `Mutator` interface and hooks so that we can register any mutator. 
- Add unit test coverage to those mutators. 
- Move provider specific mutation from the massive `register.go` to the respective packages (e.g. `git` , `github`, etc). 
- Add integration test for removal. 
- Change the decryption fallback to simply check for the repository prefix.
pull/108110/head
Roberto Jiménez Sánchez 5 days ago committed by GitHub
parent 4a779c4ccb
commit 56543db16a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      pkg/registry/apis/provisioning/controller/mutator.go
  2. 3
      pkg/registry/apis/provisioning/extra.go
  3. 67
      pkg/registry/apis/provisioning/register.go
  4. 1
      pkg/registry/apis/provisioning/repository/git/git.go
  5. 162
      pkg/registry/apis/provisioning/repository/git/git_repository_mock.go
  6. 36
      pkg/registry/apis/provisioning/repository/git/mutator.go
  7. 159
      pkg/registry/apis/provisioning/repository/git/mutator_test.go
  8. 27
      pkg/registry/apis/provisioning/repository/git/repository.go
  9. 98
      pkg/registry/apis/provisioning/repository/git/repository_test.go
  10. 220
      pkg/registry/apis/provisioning/repository/github/github_repository_mock.go
  11. 36
      pkg/registry/apis/provisioning/repository/github/mutator.go
  12. 159
      pkg/registry/apis/provisioning/repository/github/mutator_test.go
  13. 29
      pkg/registry/apis/provisioning/repository/github/repository.go
  14. 80
      pkg/registry/apis/provisioning/repository/github/repository_test.go
  15. 49
      pkg/registry/apis/provisioning/secrets/repository.go
  16. 48
      pkg/registry/apis/provisioning/secrets/repository_secrets_mock.go
  17. 169
      pkg/registry/apis/provisioning/secrets/repository_test.go
  18. 16
      pkg/registry/apis/provisioning/secrets/secret.go
  19. 48
      pkg/registry/apis/provisioning/secrets/secret_mock.go
  20. 53
      pkg/registry/apis/provisioning/secrets/secret_test.go
  21. 60
      pkg/registry/apis/provisioning/secrets/secure_value_mock.go
  22. 35
      pkg/registry/apis/provisioning/webhooks/mutator.go
  23. 157
      pkg/registry/apis/provisioning/webhooks/mutator_test.go
  24. 23
      pkg/registry/apis/provisioning/webhooks/register.go
  25. 22
      pkg/registry/apis/provisioning/webhooks/repository.go
  26. 234
      pkg/registry/apis/provisioning/webhooks/repository_test.go
  27. 92
      pkg/tests/apis/provisioning/secrets_test.go

@ -0,0 +1,9 @@
package controller
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
)
type Mutator func(ctx context.Context, obj runtime.Object) error

@ -4,6 +4,7 @@ import (
"context"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"k8s.io/apiserver/pkg/authorization/authorizer"
@ -13,12 +14,12 @@ import (
type Extra interface {
Authorize(ctx context.Context, a authorizer.Attributes) (decision authorizer.Decision, reason string, err error)
Mutate(ctx context.Context, r *provisioning.Repository) error
UpdateStorage(storage map[string]rest.Storage) error
PostProcessOpenAPI(oas *spec3.OpenAPI) error
GetJobWorkers() []jobs.Worker
AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error)
RepositoryTypes() []provisioning.RepositoryType
Mutators() []controller.Mutator
}
type ExtraBuilder func(b *APIBuilder) Extra

@ -98,6 +98,7 @@ type APIBuilder struct {
repositorySecrets secrets.RepositorySecrets
client client.ProvisioningV0alpha1Interface
access authlib.AccessChecker
mutators []controller.Mutator
statusPatcher *controller.RepositoryStatusPatcher
// Extras provides additional functionality to the API.
extras []Extra
@ -125,7 +126,13 @@ func NewAPIBuilder(
parsers := resources.NewParserFactory(clients)
resourceLister := resources.NewResourceLister(unified, unified, legacyMigrator, storageStatus)
mutators := []controller.Mutator{
git.Mutator(repositorySecrets),
github.Mutator(repositorySecrets),
}
b := &APIBuilder{
mutators: mutators,
tracer: tracer,
localFileResolver: local,
features: features,
@ -151,11 +158,13 @@ func NewAPIBuilder(
b.extras = append(b.extras, builder(b))
}
// Add the available repository types from the extras
// Add the available repository types and mutators from the extras
for _, extra := range b.extras {
for _, t := range extra.RepositoryTypes() {
b.availableRepositoryTypes[t] = true
}
b.mutators = append(b.mutators, extra.Mutators()...)
}
return b
@ -464,17 +473,9 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
r.Spec.Workflows = []provisioning.Workflow{}
}
if err := b.encryptGithubToken(ctx, r); err != nil {
return fmt.Errorf("failed to encrypt github secrets: %w", err)
}
if err := b.encryptGitToken(ctx, r); err != nil {
return fmt.Errorf("failed to encrypt git secrets: %w", err)
}
// Mutate the repository with any extra mutators
for _, extra := range b.extras {
if err := extra.Mutate(ctx, r); err != nil {
for _, mutator := range b.mutators {
if err := mutator(ctx, r); err != nil {
return fmt.Errorf("failed to mutate repository: %w", err)
}
}
@ -482,40 +483,6 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
return nil
}
// TODO: move this to a more appropriate place
func (b *APIBuilder) encryptGithubToken(ctx context.Context, repo *provisioning.Repository) error {
if repo.Spec.GitHub != nil &&
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 {
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 = ""
}
return nil
}
// TODO: move logic to a more appropriate place. Probably controller/validation.go
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
@ -1225,13 +1192,15 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
token = string(decrypted)
}
return git.NewGitRepository(ctx, r, git.RepositoryConfig{
cfg := git.RepositoryConfig{
URL: r.Spec.Git.URL,
Branch: r.Spec.Git.Branch,
Path: r.Spec.Git.Path,
Token: token,
EncryptedToken: r.Spec.Git.EncryptedToken,
})
}
return git.NewGitRepository(ctx, r, cfg, b.repositorySecrets)
case provisioning.GitHubRepositoryType:
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
logger.Info("Instantiating Github repository")
@ -1259,12 +1228,12 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
EncryptedToken: ghCfg.EncryptedToken,
}
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg, b.repositorySecrets)
if err != nil {
return nil, fmt.Errorf("error creating git repository: %w", err)
}
ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, ghToken)
ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, ghToken, b.repositorySecrets)
if err != nil {
return nil, fmt.Errorf("error creating github repository: %w", err)
}

@ -12,6 +12,7 @@ type GitRepository interface {
repository.Writer
repository.Reader
repository.StageableRepository
repository.Hooks
URL() string
Branch() string
}

@ -451,6 +451,168 @@ func (_c *MockGitRepository_ListRefs_Call) RunAndReturn(run func(context.Context
return _c
}
// OnCreate provides a mock function with given fields: ctx
func (_m *MockGitRepository) OnCreate(ctx context.Context) ([]map[string]interface{}, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnCreate")
}
var r0 []map[string]interface{}
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]map[string]interface{}, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]map[string]interface{})
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGitRepository_OnCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnCreate'
type MockGitRepository_OnCreate_Call struct {
*mock.Call
}
// OnCreate is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGitRepository_Expecter) OnCreate(ctx interface{}) *MockGitRepository_OnCreate_Call {
return &MockGitRepository_OnCreate_Call{Call: _e.mock.On("OnCreate", ctx)}
}
func (_c *MockGitRepository_OnCreate_Call) Run(run func(ctx context.Context)) *MockGitRepository_OnCreate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGitRepository_OnCreate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGitRepository_OnCreate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitRepository_OnCreate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGitRepository_OnCreate_Call {
_c.Call.Return(run)
return _c
}
// OnDelete provides a mock function with given fields: ctx
func (_m *MockGitRepository) OnDelete(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnDelete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockGitRepository_OnDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnDelete'
type MockGitRepository_OnDelete_Call struct {
*mock.Call
}
// OnDelete is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGitRepository_Expecter) OnDelete(ctx interface{}) *MockGitRepository_OnDelete_Call {
return &MockGitRepository_OnDelete_Call{Call: _e.mock.On("OnDelete", ctx)}
}
func (_c *MockGitRepository_OnDelete_Call) Run(run func(ctx context.Context)) *MockGitRepository_OnDelete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGitRepository_OnDelete_Call) Return(_a0 error) *MockGitRepository_OnDelete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockGitRepository_OnDelete_Call) RunAndReturn(run func(context.Context) error) *MockGitRepository_OnDelete_Call {
_c.Call.Return(run)
return _c
}
// OnUpdate provides a mock function with given fields: ctx
func (_m *MockGitRepository) OnUpdate(ctx context.Context) ([]map[string]interface{}, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnUpdate")
}
var r0 []map[string]interface{}
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]map[string]interface{}, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]map[string]interface{})
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGitRepository_OnUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnUpdate'
type MockGitRepository_OnUpdate_Call struct {
*mock.Call
}
// OnUpdate is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGitRepository_Expecter) OnUpdate(ctx interface{}) *MockGitRepository_OnUpdate_Call {
return &MockGitRepository_OnUpdate_Call{Call: _e.mock.On("OnUpdate", ctx)}
}
func (_c *MockGitRepository_OnUpdate_Call) Run(run func(ctx context.Context)) *MockGitRepository_OnUpdate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGitRepository_OnUpdate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGitRepository_OnUpdate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGitRepository_OnUpdate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGitRepository_OnUpdate_Call {
_c.Call.Return(run)
return _c
}
// Read provides a mock function with given fields: ctx, path, ref
func (_m *MockGitRepository) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
ret := _m.Called(ctx, path, ref)

@ -0,0 +1,36 @@
package git
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
return func(ctx context.Context, obj runtime.Object) error {
repo, ok := obj.(*provisioning.Repository)
if !ok {
return nil
}
if repo.Spec.Git == nil {
return nil
}
if repo.Spec.Git.Token != "" {
secretName := repo.Name + gitTokenSecretSuffix
nameOrValue, err := secrets.Encrypt(ctx, repo, secretName, repo.Spec.Git.Token)
if err != nil {
return err
}
repo.Spec.Git.EncryptedToken = nameOrValue
repo.Spec.Git.Token = ""
}
return nil
}
}

@ -0,0 +1,159 @@
package git
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func TestMutator(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
token string
setupMocks func(*secrets.MockRepositorySecrets)
expectedToken string
expectedEncryptedToken string
expectedError string
}{
{
name: "successful token encryption",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+gitTokenSecretSuffix,
"secret-token",
).Return([]byte("encrypted-token"), nil)
},
expectedToken: "",
expectedEncryptedToken: "encrypted-token",
},
{
name: "encryption error",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+gitTokenSecretSuffix,
"secret-token",
).Return(nil, errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "no git spec",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Git: nil,
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "empty token",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Git: &provisioning.GitRepositoryConfig{
Token: "",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "non-repository object",
obj: &runtime.Unknown{},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMocks(mockSecrets)
mutator := Mutator(mockSecrets)
err := mutator(context.Background(), tt.obj)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
// Check that token was cleared and encrypted token was set
if repo, ok := tt.obj.(*provisioning.Repository); ok && repo.Spec.Git != nil {
if tt.expectedEncryptedToken != "" {
// Token should be cleared after encryption
assert.Empty(t, repo.Spec.Git.Token, "Token should be cleared after encryption")
// EncryptedToken should be set to the expected value
assert.Equal(t, tt.expectedEncryptedToken, string(repo.Spec.Git.EncryptedToken), "EncryptedToken should match expected value")
}
}
}
})
}
}

@ -19,6 +19,7 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/log"
"github.com/grafana/nanogit/options"
@ -26,6 +27,9 @@ import (
"github.com/grafana/nanogit/protocol/hash"
)
//nolint:gosec // This is a constant for a secret suffix
const gitTokenSecretSuffix = "-git-token"
type RepositoryConfig struct {
URL string
Branch string
@ -39,12 +43,14 @@ type gitRepository struct {
config *provisioning.Repository
gitConfig RepositoryConfig
client nanogit.Client
secrets secrets.RepositorySecrets
}
func NewGitRepository(
ctx context.Context,
config *provisioning.Repository,
gitConfig RepositoryConfig,
secrets secrets.RepositorySecrets,
) (GitRepository, error) {
var opts []options.Option
if len(gitConfig.Token) > 0 {
@ -60,6 +66,7 @@ func NewGitRepository(
config: config,
gitConfig: gitConfig,
client: client,
secrets: secrets,
}, nil
}
@ -753,3 +760,23 @@ func (r *gitRepository) logger(ctx context.Context, ref string) (context.Context
return ctx, logger
}
func (r *gitRepository) OnCreate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *gitRepository) OnUpdate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *gitRepository) OnDelete(ctx context.Context) error {
logger := logging.FromContext(ctx)
secretName := r.config.Name + gitTokenSecretSuffix
if err := r.secrets.Delete(ctx, r.config, secretName); err != nil {
return fmt.Errorf("delete git token secret: %w", err)
}
logger.Info("Deleted git token secret", "secretName", secretName)
return nil
}

@ -14,6 +14,7 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/mocks"
"github.com/grafana/nanogit/protocol"
@ -290,9 +291,10 @@ func TestNewGit(t *testing.T) {
Path: "configs",
}
mockSecrets := secrets.NewMockRepositorySecrets(t)
// This should succeed in creating the client but won't be able to connect
// We just test that the basic structure is created correctly
gitRepo, err := NewGitRepository(ctx, config, gitConfig)
gitRepo, err := NewGitRepository(ctx, config, gitConfig, mockSecrets)
require.NoError(t, err)
require.NotNil(t, gitRepo)
require.Equal(t, "https://git.example.com/owner/repo.git", gitRepo.URL())
@ -1860,7 +1862,8 @@ func TestNewGitRepository(t *testing.T) {
},
}
gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig)
mockSecrets := secrets.NewMockRepositorySecrets(t)
gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig, mockSecrets)
if tt.wantError {
require.Error(t, err)
@ -2220,11 +2223,19 @@ func TestGitRepository_logger(t *testing.T) {
// First call creates the logger context
ctx1, logger1 := gitRepo.logger(ctx, "branch1")
// Second call should return the existing logger
// Second call should return the existing logger context
ctx2, logger2 := gitRepo.logger(ctx1, "branch2")
// When logger context already exists, it should return the same context
require.Equal(t, ctx1, ctx2)
require.Equal(t, logger1, logger2)
// The logger should be the same instance from the existing context
require.NotNil(t, logger1)
require.NotNil(t, logger2)
// Both loggers should be functionally equivalent since they come from the same context
// We verify this by checking that they produce the same output
require.IsType(t, logger1, logger2)
})
}
@ -2811,7 +2822,8 @@ func TestGitRepository_NewGitRepository_ClientError(t *testing.T) {
Path: "configs",
}
gitRepo, err := NewGitRepository(ctx, config, gitConfig)
mockSecrets := secrets.NewMockRepositorySecrets(t)
gitRepo, err := NewGitRepository(ctx, config, gitConfig, mockSecrets)
// We expect this to fail during client creation
require.Error(t, err)
@ -3724,3 +3736,79 @@ func TestGitRepository_CompareFiles_FilesOutsideConfiguredPath_AllStatuses(t *te
})
}
}
func TestGitRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
setupMock func(*secrets.MockRepositorySecrets)
config *provisioning.Repository
expectedError string
}{
{
name: "successful secret deletion",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+gitTokenSecretSuffix,
).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
},
{
name: "secret deletion error",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+gitTokenSecretSuffix,
).Return(errors.New("failed to delete secret"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
expectedError: "delete git token secret: failed to delete secret",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockSecrets)
gitRepo := &gitRepository{
config: tt.config,
secrets: mockSecrets,
}
err := gitRepo.OnDelete(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
mockSecrets.AssertExpectations(t)
})
}
}

@ -395,6 +395,226 @@ func (_c *MockGithubRepository_LatestRef_Call) RunAndReturn(run func(context.Con
return _c
}
// ListRefs provides a mock function with given fields: ctx
func (_m *MockGithubRepository) ListRefs(ctx context.Context) ([]v0alpha1.RefItem, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for ListRefs")
}
var r0 []v0alpha1.RefItem
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]v0alpha1.RefItem, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []v0alpha1.RefItem); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]v0alpha1.RefItem)
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGithubRepository_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs'
type MockGithubRepository_ListRefs_Call struct {
*mock.Call
}
// ListRefs is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGithubRepository_Expecter) ListRefs(ctx interface{}) *MockGithubRepository_ListRefs_Call {
return &MockGithubRepository_ListRefs_Call{Call: _e.mock.On("ListRefs", ctx)}
}
func (_c *MockGithubRepository_ListRefs_Call) Run(run func(ctx context.Context)) *MockGithubRepository_ListRefs_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGithubRepository_ListRefs_Call) Return(_a0 []v0alpha1.RefItem, _a1 error) *MockGithubRepository_ListRefs_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_ListRefs_Call) RunAndReturn(run func(context.Context) ([]v0alpha1.RefItem, error)) *MockGithubRepository_ListRefs_Call {
_c.Call.Return(run)
return _c
}
// OnCreate provides a mock function with given fields: ctx
func (_m *MockGithubRepository) OnCreate(ctx context.Context) ([]map[string]interface{}, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnCreate")
}
var r0 []map[string]interface{}
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]map[string]interface{}, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]map[string]interface{})
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGithubRepository_OnCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnCreate'
type MockGithubRepository_OnCreate_Call struct {
*mock.Call
}
// OnCreate is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGithubRepository_Expecter) OnCreate(ctx interface{}) *MockGithubRepository_OnCreate_Call {
return &MockGithubRepository_OnCreate_Call{Call: _e.mock.On("OnCreate", ctx)}
}
func (_c *MockGithubRepository_OnCreate_Call) Run(run func(ctx context.Context)) *MockGithubRepository_OnCreate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGithubRepository_OnCreate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGithubRepository_OnCreate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_OnCreate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGithubRepository_OnCreate_Call {
_c.Call.Return(run)
return _c
}
// OnDelete provides a mock function with given fields: ctx
func (_m *MockGithubRepository) OnDelete(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnDelete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockGithubRepository_OnDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnDelete'
type MockGithubRepository_OnDelete_Call struct {
*mock.Call
}
// OnDelete is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGithubRepository_Expecter) OnDelete(ctx interface{}) *MockGithubRepository_OnDelete_Call {
return &MockGithubRepository_OnDelete_Call{Call: _e.mock.On("OnDelete", ctx)}
}
func (_c *MockGithubRepository_OnDelete_Call) Run(run func(ctx context.Context)) *MockGithubRepository_OnDelete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGithubRepository_OnDelete_Call) Return(_a0 error) *MockGithubRepository_OnDelete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockGithubRepository_OnDelete_Call) RunAndReturn(run func(context.Context) error) *MockGithubRepository_OnDelete_Call {
_c.Call.Return(run)
return _c
}
// OnUpdate provides a mock function with given fields: ctx
func (_m *MockGithubRepository) OnUpdate(ctx context.Context) ([]map[string]interface{}, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnUpdate")
}
var r0 []map[string]interface{}
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]map[string]interface{}, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]map[string]interface{})
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGithubRepository_OnUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnUpdate'
type MockGithubRepository_OnUpdate_Call struct {
*mock.Call
}
// OnUpdate is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGithubRepository_Expecter) OnUpdate(ctx interface{}) *MockGithubRepository_OnUpdate_Call {
return &MockGithubRepository_OnUpdate_Call{Call: _e.mock.On("OnUpdate", ctx)}
}
func (_c *MockGithubRepository_OnUpdate_Call) Run(run func(ctx context.Context)) *MockGithubRepository_OnUpdate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGithubRepository_OnUpdate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGithubRepository_OnUpdate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_OnUpdate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGithubRepository_OnUpdate_Call {
_c.Call.Return(run)
return _c
}
// Owner provides a mock function with no fields
func (_m *MockGithubRepository) Owner() string {
ret := _m.Called()

@ -0,0 +1,36 @@
package github
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
return func(ctx context.Context, obj runtime.Object) error {
repo, ok := obj.(*provisioning.Repository)
if !ok {
return nil
}
if repo.Spec.GitHub == nil {
return nil
}
if repo.Spec.GitHub.Token != "" {
secretName := repo.Name + githubTokenSecretSuffix
nameOrValue, err := secrets.Encrypt(ctx, repo, secretName, repo.Spec.GitHub.Token)
if err != nil {
return err
}
repo.Spec.GitHub.EncryptedToken = nameOrValue
repo.Spec.GitHub.Token = ""
}
return nil
}
}

@ -0,0 +1,159 @@
package github
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func TestMutator(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
token string
setupMocks func(*secrets.MockRepositorySecrets)
expectedToken string
expectedEncryptedToken string
expectedError string
}{
{
name: "successful token encryption",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+githubTokenSecretSuffix,
"secret-token",
).Return([]byte("encrypted-token"), nil)
},
expectedToken: "",
expectedEncryptedToken: "encrypted-token",
},
{
name: "encryption error",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+githubTokenSecretSuffix,
"secret-token",
).Return(nil, errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "no github spec",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: nil,
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "empty token",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "",
},
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "non-repository object",
obj: &runtime.Unknown{},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMocks(mockSecrets)
mutator := Mutator(mockSecrets)
err := mutator(context.Background(), tt.obj)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
// Check that token was cleared and encrypted token was set
if repo, ok := tt.obj.(*provisioning.Repository); ok && repo.Spec.GitHub != nil {
if tt.expectedEncryptedToken != "" {
// Token should be cleared after encryption
assert.Empty(t, repo.Spec.GitHub.Token, "Token should be cleared after encryption")
// EncryptedToken should be set to the expected value
assert.Equal(t, tt.expectedEncryptedToken, string(repo.Spec.GitHub.EncryptedToken), "EncryptedToken should match expected value")
}
}
}
})
}
}

@ -7,19 +7,25 @@ import (
"net/url"
"strings"
"github.com/grafana/grafana-app-sdk/logging"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
//nolint:gosec // This is a constant for a secret suffix
const githubTokenSecretSuffix = "-github-token"
// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
type githubRepository struct {
gitRepo git.GitRepository
config *provisioning.Repository
gh Client // assumes github.com base URL
secrets secrets.RepositorySecrets
owner string
repo string
@ -36,6 +42,7 @@ type GithubRepository interface {
repository.Reader
repository.RepositoryWithURLs
repository.StageableRepository
repository.Hooks
Owner() string
Repo() string
Client() Client
@ -47,6 +54,7 @@ func NewGitHub(
gitRepo git.GitRepository,
factory *Factory,
token string,
secrets secrets.RepositorySecrets,
) (GithubRepository, error) {
owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
if err != nil {
@ -59,6 +67,7 @@ func NewGitHub(
gh: factory.New(ctx, token), // TODO, baseURL from config
owner: owner,
repo: repo,
secrets: secrets,
}, nil
}
@ -252,3 +261,23 @@ func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.Fi
func (r *githubRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
return r.gitRepo.Stage(ctx, opts)
}
func (r *githubRepository) OnCreate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *githubRepository) OnUpdate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *githubRepository) OnDelete(ctx context.Context) error {
logger := logging.FromContext(ctx)
secretName := r.config.Name + githubTokenSecretSuffix
if err := r.secrets.Delete(ctx, r.config, secretName); err != nil {
return fmt.Errorf("delete github token secret: %w", err)
}
logger.Info("Deleted github token secret", "secretName", secretName)
return nil
}

@ -17,6 +17,7 @@ import (
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
func TestNewGitHub(t *testing.T) {
@ -80,6 +81,8 @@ func TestNewGitHub(t *testing.T) {
gitRepo := git.NewMockGitRepository(t)
mockSecrets := secrets.NewMockRepositorySecrets(t)
// Call the function under test
repo, err := NewGitHub(
context.Background(),
@ -87,6 +90,7 @@ func TestNewGitHub(t *testing.T) {
gitRepo,
factory,
tt.token,
mockSecrets,
)
// Check results
@ -1046,3 +1050,79 @@ func TestGitHubRepositoryAccessors(t *testing.T) {
assert.Equal(t, mockClient, result)
})
}
func TestGitHubRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
setupMock func(*secrets.MockRepositorySecrets)
config *provisioning.Repository
expectedError string
}{
{
name: "successful secret deletion",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+githubTokenSecretSuffix,
).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
},
{
name: "secret deletion error",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+githubTokenSecretSuffix,
).Return(errors.New("failed to delete secret"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
expectedError: "delete github token secret: failed to delete secret",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockSecrets)
githubRepo := &githubRepository{
config: tt.config,
secrets: mockSecrets,
}
err := githubRepo.OnDelete(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
mockSecrets.AssertExpectations(t)
})
}
}

@ -2,8 +2,12 @@ package secrets
import (
"context"
"errors"
"strings"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/service"
"github.com/grafana/grafana/pkg/services/featuremgmt"
grafanasecrets "github.com/grafana/grafana/pkg/services/secrets"
@ -22,6 +26,7 @@ func ProvideRepositorySecrets(
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)
Delete(ctx context.Context, r *provisioning.Repository, nameOrValue string) error
}
// repositorySecrets provides a unified interface for encrypting and decrypting repository secrets,
@ -67,32 +72,36 @@ func (s *repositorySecrets) Encrypt(ctx context.Context, r *provisioning.Reposit
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.
// Decrypt retrieves and decrypts secret data for a repository, supporting migration between secret backends.
// The backend used for decryption is determined by a heuristic:
// - If the provided nameOrValue starts with the repository name, it is assumed to be a Kubernetes secret name
// and the new secrets service is used for decryption.
// - Otherwise, it is treated as a legacy secret value and the legacy secrets service is used.
//
// 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.
// HACK: This approach relies on checking the prefix of nameOrValue to distinguish between secret backends.
// This is a temporary workaround to support both backends during migration and should be removed once
// migration is complete.
//
// This dual-path logic is intended to support migration between secret backends.
// This method ensures compatibility and minimizes disruption during the transition 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
logger := logging.FromContext(ctx)
// HACK: this is a hack to identify if the name is a potential Kubernetes name for a secret.
if strings.HasPrefix(nameOrValue, r.GetName()) {
logger.Info("Decrypting secret with new secrets service", "name", nameOrValue)
return s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
} else {
logger.Info("Decrypting secret with legacy secrets service", "name", nameOrValue)
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
func (s *repositorySecrets) Delete(ctx context.Context, r *provisioning.Repository, nameOrValue string) error {
if s.features.IsEnabled(ctx, featuremgmt.FlagProvisioningSecretsService) {
err := s.secretsSvc.Delete(ctx, r.GetNamespace(), nameOrValue)
if err != nil && !errors.Is(err, contracts.ErrSecureValueNotFound) {
return err
}
}
return s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
return nil
}

@ -82,6 +82,54 @@ func (_c *MockRepositorySecrets_Decrypt_Call) RunAndReturn(run func(context.Cont
return _c
}
// Delete provides a mock function with given fields: ctx, r, nameOrValue
func (_m *MockRepositorySecrets) Delete(ctx context.Context, r *v0alpha1.Repository, nameOrValue string) error {
ret := _m.Called(ctx, r, nameOrValue)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string) error); ok {
r0 = rf(ctx, r, nameOrValue)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockRepositorySecrets_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockRepositorySecrets_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Repository
// - nameOrValue string
func (_e *MockRepositorySecrets_Expecter) Delete(ctx interface{}, r interface{}, nameOrValue interface{}) *MockRepositorySecrets_Delete_Call {
return &MockRepositorySecrets_Delete_Call{Call: _e.mock.On("Delete", ctx, r, nameOrValue)}
}
func (_c *MockRepositorySecrets_Delete_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository, nameOrValue string)) *MockRepositorySecrets_Delete_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_Delete_Call) Return(_a0 error) *MockRepositorySecrets_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositorySecrets_Delete_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository, string) error) *MockRepositorySecrets_Delete_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)

@ -6,6 +6,7 @@ import (
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@ -136,84 +137,165 @@ func TestRepositorySecrets_Decrypt(t *testing.T) {
tests := []struct {
name string
namespace string
featureEnabled bool
nameOrValue string
setupMocks func(*testSetup)
expectedResult []byte
expectedError string
}{
{
name: "new service success",
namespace: "test-namespace",
featureEnabled: true,
name: "new service success - name starts with repo name",
namespace: "test-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return([]byte("decrypted-data"), nil)
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return([]byte("decrypted-data"), nil)
},
expectedResult: []byte("decrypted-data"),
},
{
name: "legacy service success",
namespace: "test-namespace",
featureEnabled: false,
name: "new service error - name starts with repo name",
namespace: "test-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return([]byte("decrypted-legacy-data"), nil)
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return(nil, errors.New("new service failed"))
},
expectedError: "new service failed",
},
{
name: "legacy service success - name does not start with repo name",
namespace: "test-namespace",
nameOrValue: "legacy-encrypted-value",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("legacy-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,
name: "legacy service error - name does not start with repo name",
namespace: "test-namespace",
nameOrValue: "legacy-encrypted-value",
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)
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("legacy-encrypted-value")).Return(nil, errors.New("legacy service failed"))
},
expectedError: "legacy service failed",
},
{
name: "new service empty bytes - name starts with repo name",
namespace: "test-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return([]byte{}, nil)
},
expectedResult: []byte("decrypted-fallback-data"),
expectedResult: []byte{},
},
{
name: "legacy service fails, fallback to new succeeds",
name: "legacy service empty bytes - name does not start with repo name",
namespace: "test-namespace",
nameOrValue: "legacy-encrypted-value",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("legacy-encrypted-value")).Return([]byte{}, nil)
},
expectedResult: []byte{},
},
{
name: "custom namespace handling - name starts with repo name",
namespace: "custom-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "custom-namespace", "test-repo-secret-name").Return([]byte("test-data"), nil)
},
expectedResult: []byte("test-data"),
},
{
name: "exact repo name match - should use new service",
namespace: "test-namespace",
nameOrValue: "test-repo",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo").Return([]byte("exact-match-data"), nil)
},
expectedResult: []byte("exact-match-data"),
},
{
name: "partial repo name match - should use legacy service",
namespace: "test-namespace",
nameOrValue: "test-rep",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("test-rep")).Return([]byte("partial-match-data"), nil)
},
expectedResult: []byte("partial-match-data"),
},
{
name: "empty name - should use legacy service",
namespace: "test-namespace",
nameOrValue: "",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("")).Return([]byte("empty-name-data"), nil)
},
expectedResult: []byte("empty-name-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, tt.nameOrValue)
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_Delete(t *testing.T) {
tests := []struct {
name string
namespace string
featureEnabled bool
setupMocks func(*testSetup)
expectedError string
}{
{
name: "new service delete success",
namespace: "test-namespace",
featureEnabled: false,
featureEnabled: true,
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)
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Delete(s.ctx, "test-namespace", "secret-to-delete").Return(nil)
},
expectedResult: []byte("decrypted-new-data"),
},
{
name: "both services fail (feature flag enabled)",
name: "new service delete error",
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"))
s.mockSecrets.EXPECT().Delete(s.ctx, "test-namespace", "secret-to-delete").Return(errors.New("delete failed"))
},
expectedError: "legacy service failed",
expectedError: "delete failed",
},
{
name: "both services fail (feature flag disabled)",
name: "new service secret not found - should succeed",
namespace: "test-namespace",
featureEnabled: false,
featureEnabled: true,
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"))
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Delete(s.ctx, "test-namespace", "non-existent-secret").Return(contracts.ErrSecureValueNotFound)
},
expectedError: "new service failed",
},
{
name: "custom namespace handling",
name: "nothing for legacy",
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)
s.expectFeatureFlag(false)
},
expectedResult: []byte("test-data"),
},
}
@ -222,16 +304,19 @@ func TestRepositorySecrets_Decrypt(t *testing.T) {
setup := setupTest(t, tt.namespace)
tt.setupMocks(setup)
result, err := setup.rs.Decrypt(setup.ctx, setup.repo, "encrypted-value")
secretName := "secret-to-delete"
if tt.name == "new service secret not found - should succeed" {
secretName = "non-existent-secret"
}
err := setup.rs.Delete(setup.ctx, setup.repo, secretName)
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)
}
setup.mockSecrets.AssertExpectations(t)
})
}
}

@ -20,12 +20,14 @@ 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)
Delete(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, namespace, name string, data string) (string, error)
Decrypt(ctx context.Context, namespace string, name string) ([]byte, error)
Delete(ctx context.Context, namespace string, name string) error
}
var _ Service = (*secretsService)(nil)
@ -107,3 +109,17 @@ func (s *secretsService) Decrypt(ctx context.Context, namespace string, name str
return nil, contracts.ErrDecryptNotFound
}
func (s *secretsService) Delete(ctx context.Context, namespace string, name string) error {
ns, err := types.ParseNamespace(namespace)
if err != nil {
return err
}
ctx = identity.WithServiceIdentityContext(ctx, ns.OrgID, identity.WithServiceIdentityName(svcName))
if _, err := s.secretsSvc.Delete(ctx, xkube.Namespace(namespace), name); err != nil {
return err
}
return nil
}

@ -81,6 +81,54 @@ func (_c *MockService_Decrypt_Call) RunAndReturn(run func(context.Context, strin
return _c
}
// Delete provides a mock function with given fields: ctx, namespace, name
func (_m *MockService) Delete(ctx context.Context, namespace string, name string) error {
ret := _m.Called(ctx, namespace, name)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
r0 = rf(ctx, namespace, name)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockService_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - name string
func (_e *MockService_Expecter) Delete(ctx interface{}, namespace interface{}, name interface{}) *MockService_Delete_Call {
return &MockService_Delete_Call{Call: _e.mock.On("Delete", ctx, namespace, name)}
}
func (_c *MockService_Delete_Call) Run(run func(ctx context.Context, namespace string, name string)) *MockService_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockService_Delete_Call) Return(_a0 error) *MockService_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_Delete_Call) RunAndReturn(run func(context.Context, string, string) error) *MockService_Delete_Call {
_c.Call.Return(run)
return _c
}
// 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)

@ -410,3 +410,56 @@ func TestSecretsService_Decrypt_StaticRequesterCreation(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []byte("test-data"), result)
}
func TestSecretsService_Delete(t *testing.T) {
tests := []struct {
name string
namespace string
secretName string
setupMocks func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService)
expectedError string
}{
{
name: "delete success",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
mockSecretsSvc.EXPECT().
Delete(mock.Anything, xkube.Namespace("test-namespace"), "test-secret").
Return(&secretv1beta1.SecureValue{}, nil)
},
},
{
name: "delete returns error",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
mockSecretsSvc.EXPECT().
Delete(mock.Anything, xkube.Namespace("test-namespace"), "test-secret").
Return(nil, errors.New("delete failed"))
},
expectedError: "delete 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()
err := svc.Delete(ctx, tt.namespace, tt.secretName)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}

@ -84,6 +84,66 @@ func (_c *MockSecureValueService_Create_Call) RunAndReturn(run func(context.Cont
return _c
}
// Delete provides a mock function with given fields: ctx, namespace, name
func (_m *MockSecureValueService) Delete(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 Delete")
}
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_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockSecureValueService_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - ctx context.Context
// - namespace xkube.Namespace
// - name string
func (_e *MockSecureValueService_Expecter) Delete(ctx interface{}, namespace interface{}, name interface{}) *MockSecureValueService_Delete_Call {
return &MockSecureValueService_Delete_Call{Call: _e.mock.On("Delete", ctx, namespace, name)}
}
func (_c *MockSecureValueService_Delete_Call) Run(run func(ctx context.Context, namespace xkube.Namespace, name string)) *MockSecureValueService_Delete_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_Delete_Call) Return(_a0 *v1beta1.SecureValue, _a1 error) *MockSecureValueService_Delete_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSecureValueService_Delete_Call) RunAndReturn(run func(context.Context, xkube.Namespace, string) (*v1beta1.SecureValue, error)) *MockSecureValueService_Delete_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)

@ -0,0 +1,35 @@
package webhooks
import (
"context"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"k8s.io/apimachinery/pkg/runtime"
)
func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
return func(ctx context.Context, obj runtime.Object) error {
repo, ok := obj.(*provisioning.Repository)
if !ok {
return nil
}
if repo.Status.Webhook == nil {
return nil
}
if repo.Status.Webhook.Secret != "" {
secretName := repo.Name + webhookSecretSuffix
nameOrValue, err := secrets.Encrypt(ctx, repo, secretName, repo.Status.Webhook.Secret)
if err != nil {
return err
}
repo.Status.Webhook.EncryptedSecret = nameOrValue
repo.Status.Webhook.Secret = ""
}
return nil
}
}

@ -0,0 +1,157 @@
package webhooks
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func TestMutator(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
secret string
setupMocks func(*secrets.MockRepositorySecrets)
expectedEncryptedSecret string
expectedError string
}{
{
name: "successful secret encryption",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
"test-repo-webhook-secret",
"webhook-secret",
).Return([]byte("encrypted-webhook-secret"), nil)
},
expectedEncryptedSecret: "encrypted-webhook-secret",
},
{
name: "encryption error",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
"test-repo-webhook-secret",
"webhook-secret",
).Return(nil, errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "no webhook status",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: nil,
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "empty secret",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "",
},
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "non-repository object",
obj: &runtime.Unknown{},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMocks(mockSecrets)
mutator := Mutator(mockSecrets)
err := mutator(context.Background(), tt.obj)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
// Check that secret was cleared and encrypted secret was set
if repo, ok := tt.obj.(*provisioning.Repository); ok && repo.Status.Webhook != nil {
if tt.expectedEncryptedSecret != "" {
// Secret should be cleared after encryption
assert.Empty(t, repo.Status.Webhook.Secret, "Secret should be cleared after encryption")
// EncryptedSecret should be set to the expected value
assert.Equal(t, tt.expectedEncryptedSecret, string(repo.Status.Webhook.EncryptedSecret), "EncryptedSecret should match expected value")
}
}
}
})
}
}

@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"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/repository/git"
@ -128,21 +129,11 @@ func (e *WebhookExtra) Authorize(ctx context.Context, a authorizer.Attributes) (
return e.render.Authorize(ctx, a)
}
// Mutate delegates mutation to the webhook connector
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 != "" {
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 = nameOrValue
r.Status.Webhook.Secret = ""
// Mutators returns the mutators for the webhook extra
func (e *WebhookExtra) Mutators() []controller.Mutator {
return []controller.Mutator{
Mutator(e.secrets),
}
return nil
}
// UpdateStorage updates the storage with both render and webhook connectors
@ -206,12 +197,12 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit
EncryptedToken: ghCfg.EncryptedToken,
}
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg, e.secrets)
if err != nil {
return nil, fmt.Errorf("error creating git repository: %w", err)
}
basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken)
basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken, e.secrets)
if err != nil {
return nil, fmt.Errorf("error creating github repository: %w", err)
}

@ -20,6 +20,9 @@ import (
var subscribedEvents = []string{"push", "pull_request"}
//nolint:gosec // This is a constant for a secret suffix
const webhookSecretSuffix = "-webhook-secret"
type WebhookRepository interface {
Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error)
}
@ -269,6 +272,7 @@ func (r *githubWebhookRepository) updateWebhook(ctx context.Context) (pgh.Webhoo
}
func (r *githubWebhookRepository) deleteWebhook(ctx context.Context) error {
logger := logging.FromContext(ctx)
if r.config.Status.Webhook == nil {
return fmt.Errorf("webhook not found")
}
@ -279,7 +283,7 @@ func (r *githubWebhookRepository) deleteWebhook(ctx context.Context) error {
return fmt.Errorf("delete webhook: %w", err)
}
logging.FromContext(ctx).Info("webhook deleted", "url", r.config.Status.Webhook.URL, "id", id)
logger.Info("webhook deleted", "url", r.config.Status.Webhook.URL, "id", id)
return nil
}
@ -332,10 +336,22 @@ func (r *githubWebhookRepository) OnUpdate(ctx context.Context) ([]map[string]in
}
func (r *githubWebhookRepository) OnDelete(ctx context.Context) error {
if len(r.webhookURL) == 0 {
ctx, logger := r.logger(ctx, "")
if err := r.GithubRepository.OnDelete(ctx); err != nil {
return fmt.Errorf("on delete from basic github repository: %w", err)
}
if r.config.Status.Webhook == nil {
return nil
}
ctx, _ = r.logger(ctx, "")
secretName := r.config.Name + webhookSecretSuffix
if err := r.secrets.Delete(ctx, r.config, secretName); err != nil {
return fmt.Errorf("delete webhook secret: %w", err)
}
logger.Info("Deleted webhook secret", "secretName", secretName)
return r.deleteWebhook(ctx)
}

@ -1687,19 +1687,24 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
func TestGitHubRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
setupMock func(m *github.MockClient)
setupMock func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets)
config *provisioning.Repository
webhookURL string
expectedError error
}{
{
name: "successfully delete webhook",
setupMock: func(m *github.MockClient) {
setupMock: func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(mock.Anything, mock.Anything, mock.Anything).Return(nil)
// Mock deleting the webhook
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
@ -1717,19 +1722,32 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "no webhook URL provided",
setupMock: func(m *github.MockClient) {
// No mocks needed
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
},
config: &provisioning.Repository{},
webhookURL: "",
expectedError: nil,
},
{
name: "webhook not found in status",
setupMock: func(m *github.MockClient) {
// No mocks needed
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
// No secrets deletion or webhook deletion mocks needed - method returns early when webhook is nil
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
@ -1740,16 +1758,45 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
},
webhookURL: "https://example.com/webhook",
expectedError: fmt.Errorf("webhook not found"),
expectedError: nil, // No error expected - method returns early when webhook is nil
},
{
name: "error on delete from basic github repository",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(fmt.Errorf("failed to delete webhook"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
webhookURL: "https://example.com/webhook",
expectedError: fmt.Errorf("on delete from basic github repository: failed to delete webhook"),
},
{
name: "error deleting webhook",
setupMock: func(m *github.MockClient) {
setupMock: func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(mock.Anything, mock.Anything, mock.Anything).Return(nil)
// Mock webhook deletion failure
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(fmt.Errorf("failed to delete webhook"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
@ -1771,15 +1818,19 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Setup mock GitHub client
mockGH := github.NewMockClient(t)
tt.setupMock(mockGH)
mockRepo := github.NewMockGithubRepository(t)
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockGH, mockRepo, mockSecrets)
// Create repository with mock
repo := &githubWebhookRepository{
gh: mockGH,
config: tt.config,
owner: "grafana",
repo: "grafana",
webhookURL: tt.webhookURL,
GithubRepository: mockRepo,
gh: mockGH,
config: tt.config,
secrets: mockSecrets,
owner: "grafana",
repo: "grafana",
webhookURL: tt.webhookURL,
}
// Call the OnDelete method
@ -1798,3 +1849,156 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
})
}
}
func TestGitHubRepository_OnDelete_WithSecrets(t *testing.T) {
tests := []struct {
name string
setupMock func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets)
config *provisioning.Repository
webhookURL string
expectedError string
}{
{
name: "successful deletion with secrets",
setupMock: func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(
mock.Anything,
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
"test-repo"+webhookSecretSuffix,
).Return(nil)
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
webhookURL: "https://example.com/webhook",
},
{
name: "secret deletion error",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(
mock.Anything,
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
"test-repo"+webhookSecretSuffix,
).Return(errors.New("failed to delete webhook secret"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
webhookURL: "https://example.com/webhook",
expectedError: "delete webhook secret: failed to delete webhook secret",
},
{
name: "no webhook URL - no secrets deletion",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
},
webhookURL: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := github.NewMockClient(t)
mockRepo := github.NewMockGithubRepository(t)
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockClient, mockRepo, mockSecrets)
repo := &githubWebhookRepository{
GithubRepository: mockRepo,
gh: mockClient,
config: tt.config,
secrets: mockSecrets,
owner: "grafana",
repo: "grafana",
webhookURL: tt.webhookURL,
}
err := repo.OnDelete(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
mockClient.AssertExpectations(t)
mockRepo.AssertExpectations(t)
mockSecrets.AssertExpectations(t)
})
}
}

@ -3,12 +3,15 @@ package provisioning
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"testing"
"time"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@ -80,10 +83,11 @@ func TestIntegrationProvisioning_LegacySecrets(t *testing.T) {
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, nil, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
value, decrypted := encryptedField(t, secretsService, repo, 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)
}
@ -175,17 +179,17 @@ func TestIntegrationProvisioning_Secrets_LegacyUpdate(t *testing.T) {
// 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"]
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")
repo := unstructuredToRepository(t, output)
for _, expectedField := range test.expectedFields {
value, decrypted := encryptedField(t, secretsService, nil, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
value, decrypted := encryptedField(t, secretsService, repo, 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)
}
@ -407,6 +411,86 @@ func TestIntegrationProvisioning_Secrets_Update(t *testing.T) {
}
}
func TestIntegrationProvisioning_Secrets_Removal(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
helper := runGrafana(t, useAppPlatformSecrets)
secretsService := helper.GetEnv().RepositorySecrets
createOptions := metav1.CreateOptions{}
type expectedField struct {
Path []string
}
tests := []struct {
name string
inputFile string
values map[string]interface{}
expectedFields []expectedField
updatedFields []expectedField
}{
{
name: "remove encrypted git token",
inputFile: "testdata/git-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "git", "encryptedToken"},
},
},
},
{
name: "remove encrypted github token",
inputFile: "testdata/github-readonly.json.tmpl",
values: map[string]interface{}{
"Token": "initial-token",
},
expectedFields: []expectedField{
{
Path: []string{"spec", "github", "encryptedToken"},
},
},
},
}
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")
repo := unstructuredToRepository(t, output)
// Set the same name and resourceVersion for update
err = helper.Repositories.Resource.Delete(ctx, name, metav1.DeleteOptions{})
require.NoError(t, err, "failed to delete resource")
for _, expectedField := range test.expectedFields {
secretName, found, err := base64DecodedField(output.Object, expectedField.Path)
require.NoError(t, err, "failed to decode base64 value")
require.True(t, found, "secretName should be found")
require.NotEmpty(t, secretName)
var lastDecrypted []byte
require.Eventually(t, func() bool {
lastDecrypted, err = secretsService.Decrypt(ctx, repo, secretName)
return err != nil && errors.Is(err, contracts.ErrDecryptNotFound)
}, 1000*time.Second, 500*time.Millisecond, "expected ErrDecryptNotFound error, got %v", lastDecrypted)
}
})
}
}
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 {

Loading…
Cancel
Save