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:
co-authored by
Dana Axinte
Michael Mandrus
parent
e2737f195b
commit
6922315d7c
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user