SecretsManager: Introduce decrypt store (#107586)

Co-authored-by: Matheus Macabu <macabu@users.noreply.github.com>
Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
Co-authored-by: PoorlyDefinedBehaviour <brunotj2015@hotmail.com>
This commit is contained in:
Dana Axinte
2025-07-04 09:22:10 +01:00
committed by GitHub
co-authored by Matheus Macabu Michael Mandrus PoorlyDefinedBehaviour
parent ea4f783ebe
commit 15e1aa8855
4 changed files with 528 additions and 2 deletions
+2 -1
View File
@@ -424,9 +424,10 @@ var wireBasicSet = wire.NewSet(
// Secrets Manager
secretmetadata.ProvideSecureValueMetadataStorage,
secretmetadata.ProvideKeeperMetadataStorage,
secretmetadata.ProvideOutboxQueue,
secretmetadata.ProvideDecryptStorage,
secretencryption.ProvideDataKeyStorage,
secretencryption.ProvideEncryptedValueStorage,
secretmetadata.ProvideOutboxQueue,
secretmigrator.NewWithEngine,
secretdatabase.ProvideDatabase,
wire.Bind(new(secretcontracts.Database), new(*secretdatabase.Database)),
File diff suppressed because one or more lines are too long
@@ -0,0 +1,123 @@
package metadata
import (
"context"
"fmt"
"strconv"
"time"
claims "github.com/grafana/authlib/types"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana-app-sdk/logging"
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/storage/secret/metadata/metrics"
)
// TODO: this should be a "decrypt" service rather, so that other services can wire and call it.
func ProvideDecryptStorage(
features featuremgmt.FeatureToggles,
tracer trace.Tracer,
keeperService contracts.KeeperService,
keeperMetadataStorage contracts.KeeperMetadataStorage,
secureValueMetadataStorage contracts.SecureValueMetadataStorage,
decryptAuthorizer contracts.DecryptAuthorizer,
reg prometheus.Registerer,
) (contracts.DecryptStorage, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) ||
!features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) {
return &decryptStorage{}, nil
}
if decryptAuthorizer == nil {
return nil, fmt.Errorf("a decrypt authorizer is required")
}
return &decryptStorage{
tracer: tracer,
keeperMetadataStorage: keeperMetadataStorage,
keeperService: keeperService,
secureValueMetadataStorage: secureValueMetadataStorage,
decryptAuthorizer: decryptAuthorizer,
metrics: metrics.NewStorageMetrics(reg),
}, nil
}
// decryptStorage is the actual implementation of the decrypt storage.
type decryptStorage struct {
tracer trace.Tracer
keeperMetadataStorage contracts.KeeperMetadataStorage
keeperService contracts.KeeperService
secureValueMetadataStorage contracts.SecureValueMetadataStorage
decryptAuthorizer contracts.DecryptAuthorizer
metrics *metrics.StorageMetrics
}
// Decrypt decrypts a secure value from the keeper.
func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, name string) (_ secretv0alpha1.ExposedSecureValue, decryptErr error) {
ctx, span := s.tracer.Start(ctx, "DecryptStorage.Decrypt", trace.WithAttributes(
attribute.String("namespace", namespace.String()),
attribute.String("name", name),
))
defer span.End()
var decrypterIdentity string
start := time.Now()
// TEMPORARY: While we evaluate all of our auditing needs, provide one for decrypt operations.
defer func() {
span.SetAttributes(attribute.String("decrypter.identity", decrypterIdentity))
if decryptErr == nil {
logging.FromContext(ctx).Info("Audit log:", "operation", "decrypt_secret_success", "namespace", namespace, "secret_name", name, "decrypter_identity", decrypterIdentity)
} else {
span.SetStatus(codes.Error, "Decrypt failed")
span.RecordError(decryptErr)
logging.FromContext(ctx).Info("Audit log:", "operation", "decrypt_secret_error", "namespace", namespace, "secret_name", name, "decrypter_identity", decrypterIdentity, "error", decryptErr)
}
success := decryptErr == nil
s.metrics.DecryptDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds())
s.metrics.DecryptRequestCount.WithLabelValues(strconv.FormatBool(success)).Inc()
}()
// Basic authn check before reading a secure value metadata, it is here on purpose.
if _, ok := claims.AuthInfoFrom(ctx); !ok {
return "", contracts.ErrDecryptNotAuthorized
}
// The auth token will not necessarily have the permission to read the secure value metadata,
// but we still need to do it to inspect the `decrypters` field, hence the actual `authorize`
// function call happens after this.
sv, err := s.secureValueMetadataStorage.ReadForDecrypt(ctx, namespace, name)
if err != nil {
return "", contracts.ErrDecryptNotFound
}
decrypterIdentity, authorized := s.decryptAuthorizer.Authorize(ctx, name, sv.Decrypters)
if !authorized {
return "", contracts.ErrDecryptNotAuthorized
}
keeperConfig, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, namespace.String(), sv.Keeper, contracts.ReadOpts{})
if err != nil {
return "", contracts.ErrDecryptFailed
}
keeper, err := s.keeperService.KeeperForConfig(keeperConfig)
if err != nil {
return "", contracts.ErrDecryptFailed
}
exposedValue, err := keeper.Expose(ctx, keeperConfig, namespace.String(), contracts.ExternalID(sv.ExternalID))
if err != nil {
return "", contracts.ErrDecryptFailed
}
return exposedValue, nil
}
@@ -0,0 +1,402 @@
package metadata
import (
"context"
"testing"
"github.com/grafana/authlib/authn"
"github.com/grafana/authlib/types"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
"github.com/grafana/grafana/pkg/apimachinery/identity"
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/decrypt"
"github.com/grafana/grafana/pkg/registry/apis/secret/encryption"
encryptionmanager "github.com/grafana/grafana/pkg/registry/apis/secret/encryption/manager"
"github.com/grafana/grafana/pkg/registry/apis/secret/secretkeeper"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/secret/database"
encryptionstorage "github.com/grafana/grafana/pkg/storage/secret/encryption"
"github.com/grafana/grafana/pkg/storage/secret/migrator"
)
func TestIntegrationDecrypt(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Parallel()
t.Run("when no auth info is present, it returns an error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
decryptSvc, _, _, _ := setupDecryptTestService(t, nil)
exposed, err := decryptSvc.Decrypt(ctx, "default", "name")
require.Error(t, err)
require.Empty(t, exposed)
})
t.Run("when secure value cannot be found, it returns an error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// Create auth context with proper permissions
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues/group1:decrypt"}, "svc", types.TypeUser)
decryptSvc, _, _, _ := setupDecryptTestService(t, map[string]struct{}{"group1": {}})
exposed, err := decryptSvc.Decrypt(authCtx, "default", "non-existent-value")
require.ErrorIs(t, err, contracts.ErrDecryptNotFound)
require.Empty(t, exposed)
})
t.Run("when auth info is not in allowlist, it returns an unauthorized error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
svName := "sv-test"
svcIdentity := "svc"
// Create auth context with identity that is not in allowlist
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues/" + svName + ":decrypt"}, svcIdentity, types.TypeUser)
// Create an allowlist that doesn't include the permission
allowList := map[string]struct{}{"allowed-group": {}}
// Setup service
decryptSvc, secureValueMetadataStorage, keeperService, keeperMetadataService := setupDecryptTestService(t, allowList)
// Create a secure value that is not in the allowlist
spec := secretv0alpha1.SecureValueSpec{
Description: "description",
Decrypters: []string{svcIdentity},
Value: secretv0alpha1.NewExposedSecureValue("value"),
}
sv := &secretv0alpha1.SecureValue{Spec: spec}
sv.Name = svName
sv.Namespace = "default"
newTestSecureValue(authCtx, t, secureValueMetadataStorage, keeperService, keeperMetadataService, sv, "actor-uid")
exposed, err := decryptSvc.Decrypt(authCtx, "default", svName)
require.ErrorIs(t, err, contracts.ErrDecryptNotAuthorized)
require.Empty(t, exposed)
})
t.Run("when happy path with valid auth and permissions, it returns decrypted value", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
svcIdentity := "svc"
// Create auth context with proper permissions that match the decrypters
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, svcIdentity, types.TypeUser)
// Include the group in allowlist
allowList := map[string]struct{}{svcIdentity: {}}
// Setup service
decryptSvc, secureValueMetadataStorage, keeperService, keeperMetadataService := setupDecryptTestService(t, allowList)
// Create a secure value that is in the allowlist
spec := secretv0alpha1.SecureValueSpec{
Description: "description",
Decrypters: []string{svcIdentity},
Value: secretv0alpha1.NewExposedSecureValue("value"),
}
sv := &secretv0alpha1.SecureValue{Spec: spec}
sv.Name = "sv-test"
sv.Namespace = "default"
newTestSecureValue(authCtx, t, secureValueMetadataStorage, keeperService, keeperMetadataService, sv, "actor-uid")
exposed, err := decryptSvc.Decrypt(authCtx, "default", "sv-test")
require.NoError(t, err)
require.NotEmpty(t, exposed)
require.Equal(t, "value", exposed.DangerouslyExposeAndConsumeValue())
})
t.Run("with permissions for a specific secure value but trying to decrypt another one, it returns unauthorized error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
svName := "sv-test"
svcIdentity := "svc"
// Create auth context with proper permissions that match the decrypters
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues/sv-test2:decrypt"}, svcIdentity, types.TypeUser)
// Include the group in allowlist
allowList := map[string]struct{}{svcIdentity: {}}
// Setup service
decryptSvc, secureValueMetadataStorage, keeperService, keeperMetadataService := setupDecryptTestService(t, allowList)
// Create a secure value that is in the allowlist
spec := secretv0alpha1.SecureValueSpec{
Description: "description",
Decrypters: []string{svcIdentity},
Value: secretv0alpha1.NewExposedSecureValue("value"),
}
sv := &secretv0alpha1.SecureValue{Spec: spec}
sv.Name = svName
sv.Namespace = "default"
newTestSecureValue(authCtx, t, secureValueMetadataStorage, keeperService, keeperMetadataService, sv, "actor-uid")
exposed, err := decryptSvc.Decrypt(authCtx, "default", svName)
require.ErrorIs(t, err, contracts.ErrDecryptNotAuthorized)
require.Empty(t, exposed)
})
t.Run("when permission format is malformed (no verb), it returns unauthorized error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
svcIdentity := "svc"
// Create auth context with malformed permission (no verb)
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues"}, svcIdentity, types.TypeUser)
// Setup service
decryptSvc, secureValueMetadataStorage, keeperService, keeperMetadataService := setupDecryptTestService(t, map[string]struct{}{svcIdentity: {}})
// Create a secure value
spec := secretv0alpha1.SecureValueSpec{
Description: "description",
Decrypters: []string{svcIdentity},
Value: secretv0alpha1.NewExposedSecureValue("value"),
}
sv := &secretv0alpha1.SecureValue{Spec: spec}
sv.Name = "sv-test"
sv.Namespace = "default"
newTestSecureValue(authCtx, t, secureValueMetadataStorage, keeperService, keeperMetadataService, sv, "actor-uid")
exposed, err := decryptSvc.Decrypt(authCtx, "default", "sv-test")
require.ErrorIs(t, err, contracts.ErrDecryptNotAuthorized)
require.Empty(t, exposed)
})
t.Run("when permission verb is not 'decrypt', it returns unauthorized error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
svName := "sv-test"
svcIdentity := "svc"
// Create auth context with wrong verb
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues/" + svName + ":read"}, svcIdentity, types.TypeUser)
// Setup service
decryptSvc, secureValueMetadataStorage, keeperService, keeperMetadataService := setupDecryptTestService(t, map[string]struct{}{svcIdentity: {}})
// Create a secure value
spec := secretv0alpha1.SecureValueSpec{
Description: "description",
Decrypters: []string{svcIdentity},
Value: secretv0alpha1.NewExposedSecureValue("value"),
}
sv := &secretv0alpha1.SecureValue{Spec: spec}
sv.Name = svName
sv.Namespace = "default"
newTestSecureValue(authCtx, t, secureValueMetadataStorage, keeperService, keeperMetadataService, sv, "actor-uid")
exposed, err := decryptSvc.Decrypt(authCtx, "default", svName)
require.ErrorIs(t, err, contracts.ErrDecryptNotAuthorized)
require.Empty(t, exposed)
})
t.Run("when permission has incorrect number of parts, it returns unauthorized error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
svcIdentity := "svc"
// Create auth context with incorrect number of parts
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues/:decrypt"}, svcIdentity, types.TypeUser)
// Setup service
decryptSvc, secureValueMetadataStorage, keeperService, keeperMetadataService := setupDecryptTestService(t, map[string]struct{}{svcIdentity: {}})
// Create a secure value
spec := secretv0alpha1.SecureValueSpec{
Description: "description",
Decrypters: []string{svcIdentity},
Value: secretv0alpha1.NewExposedSecureValue("value"),
}
sv := &secretv0alpha1.SecureValue{Spec: spec}
sv.Name = "sv-test"
sv.Namespace = "default"
newTestSecureValue(authCtx, t, secureValueMetadataStorage, keeperService, keeperMetadataService, sv, "actor-uid")
exposed, err := decryptSvc.Decrypt(authCtx, "default", "sv-test")
require.ErrorIs(t, err, contracts.ErrDecryptNotAuthorized)
require.Empty(t, exposed)
})
t.Run("when permission has incorrect group or resource, it returns unauthorized error", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
svName := "sv-test"
svcIdentity := "svc"
// Create auth context with incorrect group
authCtx := createAuthContext(ctx, "default", []string{"wrong.group/securevalues/" + svName + ":decrypt"}, svcIdentity, types.TypeUser)
// Setup service
decryptSvc, secureValueMetadataStorage, keeperService, keeperMetadataService := setupDecryptTestService(t, map[string]struct{}{svcIdentity: {}})
// Create a secure value
spec := secretv0alpha1.SecureValueSpec{
Description: "description",
Decrypters: []string{svcIdentity},
Value: secretv0alpha1.NewExposedSecureValue("value"),
}
sv := &secretv0alpha1.SecureValue{Spec: spec}
sv.Name = svName
sv.Namespace = "default"
newTestSecureValue(authCtx, t, secureValueMetadataStorage, keeperService, keeperMetadataService, sv, "actor-uid")
exposed, err := decryptSvc.Decrypt(authCtx, "default", svName)
require.Error(t, err)
require.Equal(t, err.Error(), "not authorized")
require.Empty(t, exposed)
})
// TODO: add more tests for keeper failure scenarios, lets see how the async work will change this though.
}
func setupDecryptTestService(t *testing.T, allowList map[string]struct{}) (*decryptStorage, contracts.SecureValueMetadataStorage, *secretkeeper.OSSKeeperService, contracts.KeeperMetadataStorage) {
t.Helper()
// Initialize infra dependencies
cfg := &setting.Cfg{
SecretsManagement: setting.SecretsManagerSettings{
SecretKey: "sdDkslslld",
EncryptionProvider: "secretKey.v1",
},
}
features := featuremgmt.WithFeatures(
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
featuremgmt.FlagSecretsManagementAppPlatform,
)
db := sqlstore.NewTestStore(t, sqlstore.WithMigrator(migrator.New()))
tracer := noop.NewTracerProvider().Tracer("test")
database := database.ProvideDatabase(db, tracer)
// Initialize encryption manager and storage
dataKeyStore, err := encryptionstorage.ProvideDataKeyStorage(database, tracer, features, nil)
require.NoError(t, err)
encValueStore, err := encryptionstorage.ProvideEncryptedValueStorage(database, tracer, features)
require.NoError(t, err)
encryptionManager, err := encryptionmanager.ProvideEncryptionManager(
tracer,
dataKeyStore,
cfg,
&usagestats.UsageStatsMock{},
encryption.ProviderMap{},
)
require.NoError(t, err)
// Initialize the keeper service
keeperService, err := secretkeeper.ProvideService(tracer, encValueStore, encryptionManager, nil)
require.NoError(t, err)
keeperMetadataStorage, err := ProvideKeeperMetadataStorage(database, tracer, features, nil)
require.NoError(t, err)
// Initialize the secure value storage
secureValueMetadataStorage, err := ProvideSecureValueMetadataStorage(database, tracer, features, nil)
require.NoError(t, err)
decryptAuthorizer := decrypt.ProvideDecryptAuthorizer(tracer, allowList)
// Initialize the decrypt storage
decryptSvc, err := ProvideDecryptStorage(features, tracer, keeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, nil)
require.NoError(t, err)
return decryptSvc.(*decryptStorage), secureValueMetadataStorage, keeperService, keeperMetadataStorage
}
func createAuthContext(ctx context.Context, namespace string, permissions []string, svc string, identityType types.IdentityType) context.Context {
requester := &identity.StaticRequester{
Type: identityType,
Namespace: namespace,
AccessTokenClaims: &authn.Claims[authn.AccessTokenClaims]{
Rest: authn.AccessTokenClaims{
Permissions: permissions,
ServiceIdentity: svc,
},
},
}
if identityType == types.TypeUser {
requester.UserID = 1
}
return types.WithAuthInfo(ctx, requester)
}
// This helper will also delete the secureValue from the db when the test is done.
func newTestSecureValue(ctx context.Context, t *testing.T, db contracts.SecureValueMetadataStorage, keeperService *secretkeeper.OSSKeeperService, keeperMetadataStorage contracts.KeeperMetadataStorage, sv *secretv0alpha1.SecureValue, actorUID string) {
t.Helper()
_, err := db.Create(ctx, sv, actorUID)
require.NoError(t, err)
require.NoError(t, err)
// Since creating secrets is async, store the secret in the keeper synchronously to make testing easier
cfg, err := keeperMetadataStorage.GetKeeperConfig(ctx, sv.Namespace, sv.Spec.Keeper, contracts.ReadOpts{})
require.NoError(t, err)
keeper, err := keeperService.KeeperForConfig(cfg)
require.NoError(t, err)
externalID, err := keeper.Store(ctx, cfg, sv.Namespace, sv.Spec.Value.DangerouslyExposeAndConsumeValue())
require.NoError(t, err)
// Set external id for the secure value
err = db.SetExternalID(ctx, xkube.Namespace(sv.Namespace), sv.Name, externalID)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, keeper.Delete(ctx, cfg, sv.Namespace, externalID))
require.NoError(t, db.Delete(ctx, xkube.Namespace(sv.Namespace), sv.Name))
})
}