diff --git a/pkg/registry/apis/secret/contracts/encryption.go b/pkg/registry/apis/secret/contracts/encryption.go new file mode 100644 index 00000000000..164143ce423 --- /dev/null +++ b/pkg/registry/apis/secret/contracts/encryption.go @@ -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 +} diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go index edd996ed4e9..2ba2cfb05dd 100644 --- a/pkg/registry/apis/secret/contracts/keeper.go +++ b/pkg/registry/apis/secret/contracts/keeper.go @@ -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" +) diff --git a/pkg/registry/apis/secret/secretkeeper/secretkeeper.go b/pkg/registry/apis/secret/secretkeeper/secretkeeper.go new file mode 100644 index 00000000000..daf150d1543 --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/secretkeeper.go @@ -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 +} diff --git a/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go b/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go new file mode 100644 index 00000000000..8a89eefc226 --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/secretkeeper_test.go @@ -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 +} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go new file mode 100644 index 00000000000..7e35a2f6aa0 --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go @@ -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 +} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go new file mode 100644 index 00000000000..a304c6cdc4c --- /dev/null +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go @@ -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` 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 +}