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.
This commit is contained in:
Roberto Jiménez Sánchez
2025-07-16 07:38:42 +00:00
committed by GitHub
parent 4a779c4ccb
commit 56543db16a
27 changed files with 1956 additions and 174 deletions
@@ -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)
})
}
}