Secrets: Implement basic unified secret store service (#45804)
* wip: Implement kvstore for secrets
* wip: Refactor kvstore for secrets
* wip: Add format key function to secrets kvstore sql
* wip: Add migration for secrets kvstore
* Remove unused Key field from secrets kvstore
* Remove secret values from debug logs
* Integrate unified secrets with datasources
* Fix minor issues and tests for kvstore
* Create test service helper for secret store
* Remove encryption tests from datasources
* Move secret operations after datasources
* Fix datasource proxy tests
* Fix legacy data tests
* Add Name to all delete data source commands
* Implement decryption cache on sql secret store
* Fix minor issue with cache and tests
* Use secret type on secret store datasource operations
* Add comments to make create and update clear
* Rename itemFound variable to isFound
* Improve secret deletion and cache management
* Add base64 encoding to sql secret store
* Move secret retrieval to decrypted values function
* Refactor decrypt secure json data functions
* Fix expr tests
* Fix datasource tests
* Fix plugin proxy tests
* Fix query tests
* Fix metrics api tests
* Remove unused fake secrets service from query tests
* Add rename function to secret store
* Add check for error renaming secret
* Remove bus from tests to fix merge conflicts
* Add background secrets migration to datasources
* Get datasource secure json fields from secrets
* Move migration to secret store
* Revert "Move migration to secret store"
This reverts commit 7c3f872072.
* Add secret service to datasource service on tests
* Fix datasource tests
* Remove merge conflict on wire
* Add ctx to data source http transport on prometheus stats collector
* Add ctx to data source http transport on stats collector test
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package kvstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/database"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
func SetupTestService(t *testing.T) SecretsKVStore {
|
||||
t.Helper()
|
||||
|
||||
sqlStore := sqlstore.InitTestDB(t)
|
||||
store := database.ProvideSecretsStore(sqlstore.InitTestDB(t))
|
||||
secretsService := manager.SetupTestService(t, store)
|
||||
|
||||
kv := &secretsKVStoreSQL{
|
||||
sqlStore: sqlStore,
|
||||
log: log.New("secrets.kvstore"),
|
||||
secretsService: secretsService,
|
||||
decryptionCache: decryptionCache{
|
||||
cache: make(map[int64]cachedDecrypted),
|
||||
},
|
||||
}
|
||||
|
||||
return kv
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package kvstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
const (
|
||||
// Wildcard to query all organizations
|
||||
AllOrganizations = -1
|
||||
)
|
||||
|
||||
func ProvideService(sqlStore sqlstore.Store, secretsService secrets.Service) SecretsKVStore {
|
||||
return &secretsKVStoreSQL{
|
||||
sqlStore: sqlStore,
|
||||
secretsService: secretsService,
|
||||
log: log.New("secrets.kvstore"),
|
||||
decryptionCache: decryptionCache{
|
||||
cache: make(map[int64]cachedDecrypted),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SecretsKVStore is an interface for k/v store.
|
||||
type SecretsKVStore interface {
|
||||
Get(ctx context.Context, orgId int64, namespace string, typ string) (string, bool, error)
|
||||
Set(ctx context.Context, orgId int64, namespace string, typ string, value string) error
|
||||
Del(ctx context.Context, orgId int64, namespace string, typ string) error
|
||||
Keys(ctx context.Context, orgId int64, namespace string, typ string) ([]Key, error)
|
||||
Rename(ctx context.Context, orgId int64, namespace string, typ string, newNamespace string) error
|
||||
}
|
||||
|
||||
// WithType returns a kvstore wrapper with fixed orgId and type.
|
||||
func With(kv SecretsKVStore, orgId int64, namespace string, typ string) *FixedKVStore {
|
||||
return &FixedKVStore{
|
||||
kvStore: kv,
|
||||
OrgId: orgId,
|
||||
Namespace: namespace,
|
||||
Type: typ,
|
||||
}
|
||||
}
|
||||
|
||||
// FixedKVStore is a SecretsKVStore wrapper with fixed orgId, namespace and type.
|
||||
type FixedKVStore struct {
|
||||
kvStore SecretsKVStore
|
||||
OrgId int64
|
||||
Namespace string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (kv *FixedKVStore) Get(ctx context.Context) (string, bool, error) {
|
||||
return kv.kvStore.Get(ctx, kv.OrgId, kv.Namespace, kv.Type)
|
||||
}
|
||||
|
||||
func (kv *FixedKVStore) Set(ctx context.Context, value string) error {
|
||||
return kv.kvStore.Set(ctx, kv.OrgId, kv.Namespace, kv.Type, value)
|
||||
}
|
||||
|
||||
func (kv *FixedKVStore) Del(ctx context.Context) error {
|
||||
return kv.kvStore.Del(ctx, kv.OrgId, kv.Namespace, kv.Type)
|
||||
}
|
||||
|
||||
func (kv *FixedKVStore) Keys(ctx context.Context) ([]Key, error) {
|
||||
return kv.kvStore.Keys(ctx, kv.OrgId, kv.Namespace, kv.Type)
|
||||
}
|
||||
|
||||
func (kv *FixedKVStore) Rename(ctx context.Context, newNamespace string) error {
|
||||
err := kv.kvStore.Rename(ctx, kv.OrgId, kv.Namespace, kv.Type, newNamespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kv.Namespace = newNamespace
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package kvstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestCase struct {
|
||||
OrgId int64
|
||||
Namespace string
|
||||
Type string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
func (t *TestCase) Value() string {
|
||||
return fmt.Sprintf("%d:%s:%s:%d", t.OrgId, t.Namespace, t.Type, t.Revision)
|
||||
}
|
||||
|
||||
func TestKVStore(t *testing.T) {
|
||||
kv := SetupTestService(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []*TestCase{
|
||||
{
|
||||
OrgId: 0,
|
||||
Namespace: "namespace1",
|
||||
Type: "testing1",
|
||||
},
|
||||
{
|
||||
OrgId: 0,
|
||||
Namespace: "namespace2",
|
||||
Type: "testing2",
|
||||
},
|
||||
{
|
||||
OrgId: 1,
|
||||
Namespace: "namespace1",
|
||||
Type: "testing1",
|
||||
},
|
||||
{
|
||||
OrgId: 1,
|
||||
Namespace: "namespace3",
|
||||
Type: "testing3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
err := kv.Set(ctx, tc.OrgId, tc.Namespace, tc.Type, tc.Value())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("get existing keys", func(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
value, ok, err := kv.Get(ctx, tc.OrgId, tc.Namespace, tc.Type)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, tc.Value(), value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get nonexistent keys", func(t *testing.T) {
|
||||
tcs := []*TestCase{
|
||||
{
|
||||
OrgId: 0,
|
||||
Namespace: "namespace3",
|
||||
Type: "testing3",
|
||||
},
|
||||
{
|
||||
OrgId: 1,
|
||||
Namespace: "namespace2",
|
||||
Type: "testing2",
|
||||
},
|
||||
{
|
||||
OrgId: 2,
|
||||
Namespace: "namespace1",
|
||||
Type: "testing1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
value, ok, err := kv.Get(ctx, tc.OrgId, tc.Namespace, tc.Type)
|
||||
require.Nil(t, err)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, "", value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("modify existing key", func(t *testing.T) {
|
||||
tc := testCases[0]
|
||||
|
||||
value, ok, err := kv.Get(ctx, tc.OrgId, tc.Namespace, tc.Type)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tc.Value(), value)
|
||||
|
||||
tc.Revision += 1
|
||||
|
||||
err = kv.Set(ctx, tc.OrgId, tc.Namespace, tc.Type, tc.Value())
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok, err = kv.Get(ctx, tc.OrgId, tc.Namespace, tc.Type)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tc.Value(), value)
|
||||
})
|
||||
|
||||
t.Run("use fixed client", func(t *testing.T) {
|
||||
tc := testCases[0]
|
||||
|
||||
client := With(kv, tc.OrgId, tc.Namespace, tc.Type)
|
||||
fmt.Println(client.Namespace, client.OrgId, client.Type)
|
||||
|
||||
value, ok, err := client.Get(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, tc.Value(), value)
|
||||
|
||||
tc.Revision += 1
|
||||
|
||||
err = client.Set(ctx, tc.Value())
|
||||
require.NoError(t, err)
|
||||
|
||||
value, ok, err = client.Get(ctx)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tc.Value(), value)
|
||||
})
|
||||
|
||||
t.Run("deleting keys", func(t *testing.T) {
|
||||
var stillHasKeys bool
|
||||
for _, tc := range testCases {
|
||||
if _, ok, err := kv.Get(ctx, tc.OrgId, tc.Namespace, tc.Type); err == nil && ok {
|
||||
stillHasKeys = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, stillHasKeys,
|
||||
"we are going to test key deletion, but there are no keys to delete in the database")
|
||||
for _, tc := range testCases {
|
||||
err := kv.Del(ctx, tc.OrgId, tc.Namespace, tc.Type)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
_, ok, err := kv.Get(ctx, tc.OrgId, tc.Namespace, tc.Type)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok, "all keys should be deleted at this point")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("listing existing keys", func(t *testing.T) {
|
||||
kv := SetupTestService(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
namespace, typ := "listtest", "listtest"
|
||||
|
||||
testCases := []*TestCase{
|
||||
{
|
||||
OrgId: 1,
|
||||
Type: typ,
|
||||
Namespace: namespace,
|
||||
},
|
||||
{
|
||||
OrgId: 2,
|
||||
Type: typ,
|
||||
Namespace: namespace,
|
||||
},
|
||||
{
|
||||
OrgId: 3,
|
||||
Type: typ,
|
||||
Namespace: namespace,
|
||||
},
|
||||
{
|
||||
OrgId: 4,
|
||||
Type: typ,
|
||||
Namespace: namespace,
|
||||
},
|
||||
{
|
||||
OrgId: 1,
|
||||
Type: typ,
|
||||
Namespace: "other_key",
|
||||
},
|
||||
{
|
||||
OrgId: 4,
|
||||
Type: typ,
|
||||
Namespace: "another_one",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
err := kv.Set(ctx, tc.OrgId, tc.Namespace, tc.Type, tc.Value())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
keys, err := kv.Keys(ctx, AllOrganizations, namespace, typ)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 4)
|
||||
|
||||
found := 0
|
||||
|
||||
for _, key := range keys {
|
||||
for _, tc := range testCases {
|
||||
if key.OrgId == tc.OrgId && key.Namespace == tc.Namespace && key.Type == tc.Type {
|
||||
found++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, 4, found, "querying for all orgs should return 4 records")
|
||||
|
||||
keys, err = kv.Keys(ctx, 1, namespace, typ)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1, "querying for a specific org should return 1 record")
|
||||
|
||||
keys, err = kv.Keys(ctx, AllOrganizations, "not_existing_namespace", "not_existing_type")
|
||||
require.NoError(t, err, "querying a not existing namespace should not throw an error")
|
||||
require.Len(t, keys, 0, "querying a not existing namespace should return an empty slice")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kvstore
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Item stored in k/v store.
|
||||
type Item struct {
|
||||
Id int64
|
||||
OrgId *int64
|
||||
Namespace *string
|
||||
Type *string
|
||||
Value string
|
||||
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
}
|
||||
|
||||
func (i *Item) TableName() string {
|
||||
return "secrets"
|
||||
}
|
||||
|
||||
type Key struct {
|
||||
OrgId int64
|
||||
Namespace string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (i *Key) TableName() string {
|
||||
return "secrets"
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package kvstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
// secretsKVStoreSQL provides a key/value store backed by the Grafana database
|
||||
type secretsKVStoreSQL struct {
|
||||
log log.Logger
|
||||
sqlStore sqlstore.Store
|
||||
secretsService secrets.Service
|
||||
decryptionCache decryptionCache
|
||||
}
|
||||
|
||||
type decryptionCache struct {
|
||||
cache map[int64]cachedDecrypted
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
type cachedDecrypted struct {
|
||||
updated time.Time
|
||||
value string
|
||||
}
|
||||
|
||||
var b64 = base64.RawStdEncoding
|
||||
|
||||
// Get an item from the store
|
||||
func (kv *secretsKVStoreSQL) Get(ctx context.Context, orgId int64, namespace string, typ string) (string, bool, error) {
|
||||
item := Item{
|
||||
OrgId: &orgId,
|
||||
Namespace: &namespace,
|
||||
Type: &typ,
|
||||
}
|
||||
var isFound bool
|
||||
var decryptedValue []byte
|
||||
|
||||
err := kv.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||
has, err := dbSession.Get(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error getting secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
kv.log.Debug("secret value not found", "orgId", orgId, "type", typ, "namespace", namespace)
|
||||
return nil
|
||||
}
|
||||
isFound = true
|
||||
kv.log.Debug("got secret value", "orgId", orgId, "type", typ, "namespace", namespace)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil && isFound {
|
||||
kv.decryptionCache.Lock()
|
||||
defer kv.decryptionCache.Unlock()
|
||||
|
||||
if cache, present := kv.decryptionCache.cache[item.Id]; present && item.Updated.Equal(cache.updated) {
|
||||
return cache.value, isFound, err
|
||||
}
|
||||
|
||||
decodedValue, err := b64.DecodeString(item.Value)
|
||||
if err != nil {
|
||||
kv.log.Debug("error decoding secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
return string(decryptedValue), isFound, err
|
||||
}
|
||||
|
||||
decryptedValue, err = kv.secretsService.Decrypt(ctx, decodedValue)
|
||||
if err != nil {
|
||||
kv.log.Debug("error decrypting secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
return string(decryptedValue), isFound, err
|
||||
}
|
||||
|
||||
kv.decryptionCache.cache[item.Id] = cachedDecrypted{
|
||||
updated: item.Updated,
|
||||
value: string(decryptedValue),
|
||||
}
|
||||
}
|
||||
|
||||
return string(decryptedValue), isFound, err
|
||||
}
|
||||
|
||||
// Set an item in the store
|
||||
func (kv *secretsKVStoreSQL) Set(ctx context.Context, orgId int64, namespace string, typ string, value string) error {
|
||||
encryptedValue, err := kv.secretsService.Encrypt(ctx, []byte(value), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
kv.log.Debug("error encrypting secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
return err
|
||||
}
|
||||
encodedValue := b64.EncodeToString(encryptedValue)
|
||||
return kv.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||
item := Item{
|
||||
OrgId: &orgId,
|
||||
Namespace: &namespace,
|
||||
Type: &typ,
|
||||
}
|
||||
|
||||
has, err := dbSession.Get(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error checking secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if has && item.Value == encodedValue {
|
||||
kv.log.Debug("secret value not changed", "orgId", orgId, "type", typ, "namespace", namespace)
|
||||
return nil
|
||||
}
|
||||
|
||||
item.Value = encodedValue
|
||||
item.Updated = time.Now()
|
||||
|
||||
if has {
|
||||
// if item already exists we update it
|
||||
_, err = dbSession.ID(item.Id).Update(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error updating secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
} else {
|
||||
kv.decryptionCache.cache[item.Id] = cachedDecrypted{
|
||||
updated: item.Updated,
|
||||
value: value,
|
||||
}
|
||||
kv.log.Debug("secret value updated", "orgId", orgId, "type", typ, "namespace", namespace)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// if item doesn't exist we create it
|
||||
item.Created = item.Updated
|
||||
_, err = dbSession.Insert(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error inserting secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
} else {
|
||||
kv.log.Debug("secret value inserted", "orgId", orgId, "type", typ, "namespace", namespace)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// Del deletes an item from the store.
|
||||
func (kv *secretsKVStoreSQL) Del(ctx context.Context, orgId int64, namespace string, typ string) error {
|
||||
err := kv.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||
item := Item{
|
||||
OrgId: &orgId,
|
||||
Namespace: &namespace,
|
||||
Type: &typ,
|
||||
}
|
||||
|
||||
has, err := dbSession.Get(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error checking secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if has {
|
||||
// if item exists we delete it
|
||||
_, err = dbSession.ID(item.Id).Delete(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error deleting secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
} else {
|
||||
delete(kv.decryptionCache.cache, item.Id)
|
||||
kv.log.Debug("secret value deleted", "orgId", orgId, "type", typ, "namespace", namespace)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Keys get all keys for a given namespace. To query for all
|
||||
// organizations the constant 'kvstore.AllOrganizations' can be passed as orgId.
|
||||
func (kv *secretsKVStoreSQL) Keys(ctx context.Context, orgId int64, namespace string, typ string) ([]Key, error) {
|
||||
var keys []Key
|
||||
err := kv.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||
query := dbSession.Where("namespace = ?", namespace).And("type = ?", typ)
|
||||
if orgId != AllOrganizations {
|
||||
query.And("org_id = ?", orgId)
|
||||
}
|
||||
return query.Find(&keys)
|
||||
})
|
||||
return keys, err
|
||||
}
|
||||
|
||||
// Rename an item in the store
|
||||
func (kv *secretsKVStoreSQL) Rename(ctx context.Context, orgId int64, namespace string, typ string, newNamespace string) error {
|
||||
return kv.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||
item := Item{
|
||||
OrgId: &orgId,
|
||||
Namespace: &namespace,
|
||||
Type: &typ,
|
||||
}
|
||||
|
||||
has, err := dbSession.Get(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error checking secret value", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
item.Namespace = &newNamespace
|
||||
item.Updated = time.Now()
|
||||
|
||||
if has {
|
||||
// if item already exists we update it
|
||||
_, err = dbSession.ID(item.Id).Update(&item)
|
||||
if err != nil {
|
||||
kv.log.Debug("error updating secret namespace", "orgId", orgId, "type", typ, "namespace", namespace, "err", err)
|
||||
} else {
|
||||
kv.log.Debug("secret namespace updated", "orgId", orgId, "type", typ, "namespace", namespace)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user