SecretsManager: Add Keeper service and SQL Keeper (#102554)

Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com>
Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
This commit is contained in:
Matheus Macabu
2025-03-21 11:38:43 +01:00
committed by GitHub
co-authored by Dana Axinte Michael Mandrus
parent e2737f195b
commit 6922315d7c
6 changed files with 490 additions and 0 deletions
@@ -0,0 +1,49 @@
package contracts
import "context"
// EncryptionManager is an envelope encryption service in charge of encrypting/decrypting secrets.
type EncryptionManager interface {
// Encrypt MUST NOT be used within database transactions, it may cause database locks.
// For those specific use cases where the encryption operation cannot be moved outside
// the database transaction, look at database-specific methods present at the specific
// implementation present at manager.EncryptionService.
Encrypt(ctx context.Context, namespace string, payload []byte, opt EncryptionOptions) ([]byte, error)
Decrypt(ctx context.Context, namespace string, payload []byte) ([]byte, error)
RotateDataKeys(ctx context.Context, namespace string) error
ReEncryptDataKeys(ctx context.Context, namespace string) error
}
type EncryptionOptions func() string
// EncryptWithoutScope uses a root level data key for encryption (DEK),
// in other words this DEK is not bound to any specific scope (not attached to any user, org, etc.).
func EncryptWithoutScope() EncryptionOptions {
return func() string {
return "root"
}
}
// EncryptWithScope uses a data key for encryption bound to some specific scope (i.e., user, org, etc.).
// Scope should look like "user:10", "org:1".
func EncryptWithScope(scope string) EncryptionOptions {
return func() string {
return scope
}
}
type EncryptedValue struct {
UID string
Namespace string
EncryptedData []byte
Created int64
Updated int64
}
type EncryptedValueStorage interface {
Create(ctx context.Context, namespace string, encryptedData []byte) (*EncryptedValue, error)
Update(ctx context.Context, namespace string, uid string, encryptedData []byte) error
Get(ctx context.Context, namespace string, uid string) (*EncryptedValue, error)
Delete(ctx context.Context, namespace string, uid string) error
}
@@ -21,3 +21,35 @@ type KeeperMetadataStorage interface {
Delete(ctx context.Context, namespace xkube.Namespace, name string) error
List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.KeeperList, error)
}
// KeeperType represents the type of a Keeper.
type KeeperType string
const (
SQLKeeperType KeeperType = "sql"
AWSKeeperType KeeperType = "aws"
AzureKeeperType KeeperType = "azure"
GCPKeeperType KeeperType = "gcp"
HashiCorpKeeperType KeeperType = "hashicorp"
)
// ExternalID represents either the secure value's GUID or ref (in case of external secret references).
// This is saved in the secure_value metadata storage as `external_id`.
// TODO: this does not belong in the k8s spec, but it is used by us internally. Place it somewhere appropriate.
type ExternalID string
func (s ExternalID) String() string {
return string(s)
}
// Keeper is the interface for secret keepers.
type Keeper interface {
Store(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, exposedValueOrRef string) (ExternalID, error)
Update(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID, exposedValueOrRef string) error
Expose(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID) (secretv0alpha1.ExposedSecureValue, error)
Delete(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID ExternalID) error
}
const (
DefaultSQLKeeper = "kp-default-sql"
)
@@ -0,0 +1,45 @@
package secretkeeper
import (
"fmt"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper/sqlkeeper"
)
// Service is the interface for secret keeper services.
// This exists because OSS and Enterprise have different amounts of keepers available.
type Service interface {
GetKeepers() (map[contracts.KeeperType]contracts.Keeper, error)
}
// OSSKeeperService is the OSS implementation of the Service interface.
type OSSKeeperService struct {
tracer tracing.Tracer
encryptionManager contracts.EncryptionManager
store contracts.EncryptedValueStorage
}
func ProvideService(
tracer tracing.Tracer,
store contracts.EncryptedValueStorage,
encryptionManager contracts.EncryptionManager,
) (OSSKeeperService, error) {
return OSSKeeperService{
tracer: tracer,
encryptionManager: encryptionManager,
store: store,
}, nil
}
func (ks OSSKeeperService) GetKeepers() (map[contracts.KeeperType]contracts.Keeper, error) {
sqlKeeper, err := sqlkeeper.NewSQLKeeper(ks.tracer, ks.encryptionManager, ks.store)
if err != nil {
return nil, fmt.Errorf("failed to create sql keeper: %w", err)
}
return map[contracts.KeeperType]contracts.Keeper{
contracts.SQLKeeperType: sqlKeeper,
}, nil
}
@@ -0,0 +1,35 @@
package secretkeeper
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper/sqlkeeper"
"github.com/grafana/grafana/pkg/setting"
)
func Test_OSSKeeperService_GetKeepers(t *testing.T) {
cfg := setting.NewCfg()
keeperService, err := setupTestService(t, cfg)
require.NoError(t, err)
t.Run("GetKeepers should return a map with a sql keeper", func(t *testing.T) {
keeperMap, err := keeperService.GetKeepers()
require.NoError(t, err)
assert.NotNil(t, keeperMap)
assert.Equal(t, 1, len(keeperMap))
assert.IsType(t, &sqlkeeper.SQLKeeper{}, keeperMap[contracts.SQLKeeperType])
})
}
func setupTestService(t *testing.T, cfg *setting.Cfg) (OSSKeeperService, error) {
// Initialize the keeper service
keeperService, err := ProvideService(tracing.InitializeTracerForTest(), nil, nil)
return keeperService, err
}
@@ -0,0 +1,92 @@
package sqlkeeper
import (
"context"
"fmt"
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
type SQLKeeper struct {
tracer tracing.Tracer
encryptionManager contracts.EncryptionManager
store contracts.EncryptedValueStorage
}
var _ contracts.Keeper = (*SQLKeeper)(nil)
func NewSQLKeeper(
tracer tracing.Tracer,
encryptionManager contracts.EncryptionManager,
store contracts.EncryptedValueStorage,
) (*SQLKeeper, error) {
return &SQLKeeper{
tracer: tracer,
encryptionManager: encryptionManager,
store: store,
}, nil
}
func (s *SQLKeeper) Store(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, exposedValueOrRef string) (contracts.ExternalID, error) {
ctx, span := s.tracer.Start(ctx, "sqlKeeper.Store")
defer span.End()
encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef), contracts.EncryptWithoutScope())
if err != nil {
return "", fmt.Errorf("unable to encrypt value: %w", err)
}
encryptedVal, err := s.store.Create(ctx, namespace, encryptedData)
if err != nil {
return "", fmt.Errorf("unable to store encrypted value: %w", err)
}
return contracts.ExternalID(encryptedVal.UID), nil
}
func (s *SQLKeeper) Expose(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID) (secretv0alpha1.ExposedSecureValue, error) {
ctx, span := s.tracer.Start(ctx, "sqlKeeper.Expose")
defer span.End()
encryptedValue, err := s.store.Get(ctx, namespace, externalID.String())
if err != nil {
return "", fmt.Errorf("unable to get encrypted value: %w", err)
}
exposedBytes, err := s.encryptionManager.Decrypt(ctx, namespace, encryptedValue.EncryptedData)
if err != nil {
return "", fmt.Errorf("unable to decrypt value: %w", err)
}
exposedValue := secretv0alpha1.NewExposedSecureValue(string(exposedBytes))
return exposedValue, nil
}
func (s *SQLKeeper) Delete(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID) error {
ctx, span := s.tracer.Start(ctx, "sqlKeeper.Delete")
defer span.End()
err := s.store.Delete(ctx, namespace, externalID.String())
if err != nil {
return fmt.Errorf("failed to delete encrypted value: %w", err)
}
return nil
}
func (s *SQLKeeper) Update(ctx context.Context, cfg secretv0alpha1.KeeperConfig, namespace string, externalID contracts.ExternalID, exposedValueOrRef string) error {
ctx, span := s.tracer.Start(ctx, "sqlKeeper.Update")
defer span.End()
encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef), contracts.EncryptWithoutScope())
if err != nil {
return fmt.Errorf("unable to encrypt value: %w", err)
}
err = s.store.Update(ctx, namespace, externalID.String(), encryptedData)
if err != nil {
return fmt.Errorf("failed to update encrypted value: %w", err)
}
return nil
}
@@ -0,0 +1,237 @@
package sqlkeeper
import (
"context"
"encoding/base64"
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/setting"
)
// Make this a `TestIntegration<name>` once we have the real storage implementation
func Test_SQLKeeperSetup(t *testing.T) {
ctx := context.Background()
namespace1 := "namespace1"
namespace2 := "namespace2"
plaintext1 := "very secret string in namespace 1"
plaintext2 := "very secret string in namespace 2"
nonExistentID := contracts.ExternalID("non existent")
cfg := setting.NewCfg()
sqlKeeper, err := setupTestService(t, cfg)
require.NoError(t, err)
require.NotNil(t, sqlKeeper)
t.Run("storing an encrypted value returns no error", func(t *testing.T) {
externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalId1)
externalId2, err := sqlKeeper.Store(ctx, nil, namespace2, plaintext2)
require.NoError(t, err)
require.NotEmpty(t, externalId2)
t.Run("expose the encrypted value from existing namespace", func(t *testing.T) {
exposedVal1, err := sqlKeeper.Expose(ctx, nil, namespace1, externalId1)
require.NoError(t, err)
require.NotNil(t, exposedVal1)
assert.Equal(t, plaintext1, exposedVal1.DangerouslyExposeAndConsumeValue())
exposedVal2, err := sqlKeeper.Expose(ctx, nil, namespace2, externalId2)
require.NoError(t, err)
require.NotNil(t, exposedVal2)
assert.Equal(t, plaintext2, exposedVal2.DangerouslyExposeAndConsumeValue())
})
t.Run("expose encrypted value from different namespace returns error", func(t *testing.T) {
exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace2, externalId1)
require.Error(t, err)
assert.Empty(t, exposedVal)
exposedVal, err = sqlKeeper.Expose(ctx, nil, namespace1, externalId2)
require.Error(t, err)
assert.Empty(t, exposedVal)
})
})
t.Run("storing same value in same namespace returns no error", func(t *testing.T) {
externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalId1)
externalId2, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalId2)
assert.NotEqual(t, externalId1, externalId2)
})
t.Run("storing same value in different namespace returns no error", func(t *testing.T) {
externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalId1)
externalId2, err := sqlKeeper.Store(ctx, nil, namespace2, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalId2)
assert.NotEqual(t, externalId1, externalId2)
})
t.Run("exposing non existing values returns error", func(t *testing.T) {
exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, nonExistentID)
require.Error(t, err)
assert.Empty(t, exposedVal)
})
t.Run("deleting an existing encrypted value does not return error", func(t *testing.T) {
externalID, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalID)
exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, externalID)
require.NoError(t, err)
assert.NotNil(t, exposedVal)
assert.Equal(t, plaintext1, exposedVal.DangerouslyExposeAndConsumeValue())
err = sqlKeeper.Delete(ctx, nil, namespace1, externalID)
require.NoError(t, err)
})
t.Run("deleting an non existing encrypted value does not return error", func(t *testing.T) {
err = sqlKeeper.Delete(ctx, nil, namespace1, nonExistentID)
require.NoError(t, err)
})
t.Run("updating an existent encrypted value returns no error", func(t *testing.T) {
externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalId1)
err = sqlKeeper.Update(ctx, nil, namespace1, externalId1, plaintext2)
require.NoError(t, err)
exposedVal, err := sqlKeeper.Expose(ctx, nil, namespace1, externalId1)
require.NoError(t, err)
assert.NotNil(t, exposedVal)
assert.Equal(t, plaintext2, exposedVal.DangerouslyExposeAndConsumeValue())
})
t.Run("updating a non existent encrypted value returns error", func(t *testing.T) {
externalId1, err := sqlKeeper.Store(ctx, nil, namespace1, plaintext1)
require.NoError(t, err)
require.NotEmpty(t, externalId1)
err = sqlKeeper.Update(ctx, nil, namespace1, nonExistentID, plaintext2)
require.Error(t, err)
})
}
func setupTestService(t *testing.T, cfg *setting.Cfg) (*SQLKeeper, error) {
// Initialize the encryption manager with in-memory implementation
encMgr := &inMemoryEncryptionManager{}
// Initialize encrypted value storage with in-memory implementation
encValueStore := newInMemoryEncryptedValueStorage()
// Initialize the SQLKeeper
sqlKeeper, err := NewSQLKeeper(tracing.InitializeTracerForTest(), encMgr, encValueStore)
return sqlKeeper, err
}
// While we don't have the real implementation, use an in-memory one
type inMemoryEncryptionManager struct{}
func (m *inMemoryEncryptionManager) Encrypt(_ context.Context, _ string, value []byte, _ contracts.EncryptionOptions) ([]byte, error) {
return []byte(base64.StdEncoding.EncodeToString(value)), nil
}
func (m *inMemoryEncryptionManager) Decrypt(_ context.Context, _ string, value []byte) ([]byte, error) {
return base64.StdEncoding.DecodeString(string(value))
}
func (m *inMemoryEncryptionManager) ReEncryptDataKeys(_ context.Context, _ string) error {
return nil
}
func (m *inMemoryEncryptionManager) RotateDataKeys(_ context.Context, _ string) error {
return nil
}
// While we don't have the real implementation, use an in-memory one
type inMemoryEncryptedValueStorage struct {
mu sync.RWMutex
store map[string]*contracts.EncryptedValue
}
func newInMemoryEncryptedValueStorage() *inMemoryEncryptedValueStorage {
return &inMemoryEncryptedValueStorage{
store: make(map[string]*contracts.EncryptedValue),
}
}
func (m *inMemoryEncryptedValueStorage) Create(_ context.Context, namespace string, encryptedData []byte) (*contracts.EncryptedValue, error) {
m.mu.Lock()
defer m.mu.Unlock()
uid := fmt.Sprintf("%d", len(m.store)+1) // Generate simple incremental IDs
encValue := &contracts.EncryptedValue{
UID: uid,
Namespace: namespace,
EncryptedData: encryptedData,
Created: 1, // Dummy timestamp
Updated: 1, // Dummy timestamp
}
compositeKey := namespace + ":" + uid
m.store[compositeKey] = encValue
return encValue, nil
}
func (m *inMemoryEncryptedValueStorage) Get(_ context.Context, namespace string, uid string) (*contracts.EncryptedValue, error) {
m.mu.RLock()
defer m.mu.RUnlock()
compositeKey := namespace + ":" + uid
encValue, exists := m.store[compositeKey]
if !exists {
return nil, fmt.Errorf("value not found for namespace %s and uid %s", namespace, uid)
}
return encValue, nil
}
func (m *inMemoryEncryptedValueStorage) Delete(_ context.Context, namespace string, uid string) error {
m.mu.Lock()
defer m.mu.Unlock()
compositeKey := namespace + ":" + uid
delete(m.store, compositeKey)
return nil
}
func (m *inMemoryEncryptedValueStorage) Update(_ context.Context, namespace string, uid string, encryptedData []byte) error {
m.mu.Lock()
defer m.mu.Unlock()
compositeKey := namespace + ":" + uid
encValue, exists := m.store[compositeKey]
if !exists {
return fmt.Errorf("value not found for namespace %s and uid %s", namespace, uid)
}
encValue.EncryptedData = encryptedData
encValue.Updated = 2 // Update timestamp
return nil
}