Secrets service: Allow decrypt through gRPC connection (#108365)

This commit is contained in:
Stephanie Hingtgen
2025-07-29 07:51:37 -05:00
committed by GitHub
parent 73d64d3e46
commit ef9f9c2d8e
15 changed files with 717 additions and 62 deletions
@@ -22,6 +22,51 @@ func (_m *MockDecryptService) EXPECT() *MockDecryptService_Expecter {
return &MockDecryptService_Expecter{mock: &_m.Mock}
}
// Close provides a mock function with no fields
func (_m *MockDecryptService) Close() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Close")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// MockDecryptService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
type MockDecryptService_Close_Call struct {
*mock.Call
}
// Close is a helper method to define mock.On call
func (_e *MockDecryptService_Expecter) Close() *MockDecryptService_Close_Call {
return &MockDecryptService_Close_Call{Call: _e.mock.On("Close")}
}
func (_c *MockDecryptService_Close_Call) Run(run func()) *MockDecryptService_Close_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockDecryptService_Close_Call) Return(_a0 error) *MockDecryptService_Close_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockDecryptService_Close_Call) RunAndReturn(run func() error) *MockDecryptService_Close_Call {
_c.Call.Return(run)
return _c
}
// Decrypt provides a mock function with given fields: ctx, namespace, names
func (_m *MockDecryptService) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) {
_va := make([]interface{}, len(names))
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/services/featuremgmt"
legacysecrets "github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting"
)
func ProvideRepositorySecrets(
@@ -18,8 +19,9 @@ func ProvideRepositorySecrets(
legacySecretsSvc legacysecrets.Service,
secretsSvc contracts.SecureValueClient,
decryptSvc secret.DecryptService,
cfg *setting.Cfg,
) RepositorySecrets {
return NewRepositorySecrets(features, NewSecretsService(secretsSvc, decryptSvc), NewSingleTenant(legacySecretsSvc))
return NewRepositorySecrets(features, NewSecretsService(secretsSvc, decryptSvc, cfg), NewSingleTenant(legacySecretsSvc))
}
//go:generate mockery --name RepositorySecrets --structname MockRepositorySecrets --inpackage --filename repository_secrets_mock.go --with-expecter
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/setting"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -31,12 +32,14 @@ var _ Service = (*secretsService)(nil)
type secretsService struct {
secureValues SecureValueClient
decryptSvc secret.DecryptService
cfg *setting.Cfg
}
func NewSecretsService(secretsSvc SecureValueClient, decryptSvc secret.DecryptService) Service {
func NewSecretsService(secretsSvc SecureValueClient, decryptSvc secret.DecryptService, cfg *setting.Cfg) Service {
return &secretsService{
secureValues: secretsSvc,
decryptSvc: decryptSvc,
cfg: cfg,
}
}
@@ -74,6 +77,11 @@ func (s *secretsService) Encrypt(ctx context.Context, namespace, name string, da
return result.GetName(), nil
}
decrypters := []string{svcName}
if s.cfg.SecretsManagement.DecryptGrafanaServiceName != "" {
decrypters = append(decrypters, s.cfg.SecretsManagement.DecryptGrafanaServiceName)
}
// Create the secret directly as unstructured
secret := &unstructured.Unstructured{
Object: map[string]interface{}{
@@ -86,7 +94,7 @@ func (s *secretsService) Encrypt(ctx context.Context, namespace, name string, da
"spec": map[string]interface{}{
"description": "provisioning: " + name,
"value": data,
"decrypters": []string{svcName},
"decrypters": decrypters,
},
},
}
@@ -9,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets/mocks"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -50,7 +51,7 @@ func TestNewSecretsService(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &mocks.MockDecryptService{}
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
assert.NotNil(t, svc)
assert.IsType(t, &secretsService{}, svc)
@@ -208,7 +209,7 @@ func TestSecretsService_Encrypt(t *testing.T) {
tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
@@ -232,7 +233,7 @@ func TestSecretsService_Encrypt_ClientError(t *testing.T) {
// Setup client to return error
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(nil, errors.New("client error"))
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
@@ -331,7 +332,7 @@ func TestSecretsService_Decrypt(t *testing.T) {
tt.setupMocks(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
@@ -368,7 +369,7 @@ func TestSecretsService_Decrypt_ServiceIdentityContext(t *testing.T) {
"test-secret": mockResult,
}, nil)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
result, err := svc.Decrypt(ctx, "test-namespace", "test-secret")
@@ -430,7 +431,7 @@ func TestSecretsService_Delete(t *testing.T) {
tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
err := svc.Delete(ctx, tt.namespace, tt.secretName)
@@ -515,7 +516,7 @@ func TestSecretsService_Encrypt_WithK8sNotFoundError(t *testing.T) {
}
mockResourceInterface.createErr = nil
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
result, err := svc.Encrypt(ctx, "test-namespace", "test-secret", "secret-data")
@@ -536,7 +537,7 @@ func TestSecretsService_Delete_WithK8sNotFoundError(t *testing.T) {
k8sNotFoundErr := apierrors.NewNotFound(schema.GroupResource{Group: "secret.grafana.app", Resource: "securevalues"}, "test-secret")
mockResourceInterface.deleteErr = k8sNotFoundErr
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
err := svc.Delete(ctx, "test-namespace", "test-secret")
@@ -557,7 +558,7 @@ func TestSecretsService_Delete_WithGrafanaNotFoundError(t *testing.T) {
// Mock Delete call to return Grafana not found error
mockResourceInterface.deleteErr = contracts.ErrSecureValueNotFound
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, &setting.Cfg{})
ctx := context.Background()
err := svc.Delete(ctx, "test-namespace", "test-secret")
@@ -5,8 +5,9 @@ package secrets
import (
context "context"
mock "github.com/stretchr/testify/mock"
dynamic "k8s.io/client-go/dynamic"
mock "github.com/stretchr/testify/mock"
)
// MockSecureValueClient is an autogenerated mock type for the SecureValueClient type
@@ -8,6 +8,9 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
)
// HeaderGrafanaServiceIdentityName is used to pass the service identity in the gRPC request metadata.
const HeaderGrafanaServiceIdentityName = "X-Grafana-Service-Identity-Name"
var (
ErrDecryptNotFound = errors.New("not found")
ErrDecryptNotAuthorized = errors.New("not authorized")
@@ -27,6 +30,7 @@ type DecryptAuthorizer interface {
// DecryptService is the inferface for the decrypt service.
type DecryptService interface {
Decrypt(ctx context.Context, namespace string, names ...string) (map[string]DecryptResult, error)
Close() error
}
// DecryptResult is the (union) result of a decryption operation.
@@ -0,0 +1,178 @@
package decrypt
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"os"
"github.com/fullstorydev/grpchan"
authnlib "github.com/grafana/authlib/authn"
claims "github.com/grafana/authlib/types"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
decryptv1beta1 "github.com/grafana/grafana/apps/secret/decrypt/v1beta1"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
type GRPCDecryptClient struct {
conn *grpc.ClientConn
client decryptv1beta1.SecureValueDecrypterClient
}
var _ contracts.DecryptService = &GRPCDecryptClient{}
type TLSConfig struct {
UseTLS bool
CertFile string
KeyFile string
CAFile string
ServerName string
InsecureSkipVerify bool
}
func NewGRPCDecryptClient(tokenExchanger authnlib.TokenExchanger, tracer trace.Tracer, namespace, address string) (*GRPCDecryptClient, error) {
return NewGRPCDecryptClientWithTLS(tokenExchanger, tracer, namespace, address, TLSConfig{})
}
func NewGRPCDecryptClientWithTLS(
tokenExchanger authnlib.TokenExchanger,
tracer trace.Tracer,
namespace string,
address string,
tlsConfig TLSConfig,
) (*GRPCDecryptClient, error) {
var opts []grpc.DialOption
if tlsConfig.UseTLS {
creds, err := createTLSCredentials(tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to setup TLS: %w", err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
conn, err := grpc.NewClient(address, opts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to grpc decrypt server at %s: %w", address, err)
}
tokenExchangerInterceptor := authnlib.NewGrpcClientInterceptor(
tokenExchanger,
authnlib.WithClientInterceptorTracer(tracer),
authnlib.WithClientInterceptorNamespace(namespace),
authnlib.WithClientInterceptorAudience([]string{secretv1beta1.APIGroup}),
)
clientConn := grpchan.InterceptClientConn(
conn,
tokenExchangerInterceptor.UnaryClientInterceptor,
tokenExchangerInterceptor.StreamClientInterceptor,
)
return &GRPCDecryptClient{
conn: conn,
client: decryptv1beta1.NewSecureValueDecrypterClient(clientConn),
}, nil
}
func createTLSCredentials(config TLSConfig) (credentials.TransportCredentials, error) {
tlsConfig := &tls.Config{}
if config.CAFile != "" {
caCert, err := os.ReadFile(config.CAFile)
if err != nil {
return nil, fmt.Errorf("failed to read CA: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to append CA")
}
tlsConfig.RootCAs = caCertPool
}
if config.CertFile != "" && config.KeyFile != "" {
cert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
if config.ServerName != "" {
tlsConfig.ServerName = config.ServerName
}
if config.InsecureSkipVerify {
tlsConfig.InsecureSkipVerify = true
}
return credentials.NewTLS(tlsConfig), nil
}
func (g *GRPCDecryptClient) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
func (g *GRPCDecryptClient) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) {
authInfo, ok := claims.AuthInfoFrom(ctx)
if !ok {
return nil, errors.New("missing auth info in context")
}
// Up until here the identity is the one set by the internal service, but when the request goes out to the gRPC server,
// the aggregator will use the access token which contains a different service identity for grafana as a whole.
// This is used for logging purposes only.
serviceIdentityList, ok := authInfo.GetExtra()[authnlib.ServiceIdentityKey]
if !ok || len(serviceIdentityList) != 1 {
return nil, errors.New("invalid service identity in auth info")
}
serviceIdentity := serviceIdentityList[0]
if len(serviceIdentity) == 0 {
return nil, errors.New("empty service identity in auth info")
}
req := &decryptv1beta1.SecureValueDecryptRequest{
Namespace: namespace,
Names: names,
}
// Decryption will still use the service identity from the auth token,
// but we also pass the service identity from the request metadata for auditing purposes.
md := metadata.New(map[string]string{
contracts.HeaderGrafanaServiceIdentityName: serviceIdentity,
})
ctx = metadata.NewOutgoingContext(ctx, md)
resp, err := g.client.DecryptSecureValues(ctx, req)
if err != nil {
return nil, fmt.Errorf("grpc decrypt failed: %w", err)
}
results := make(map[string]contracts.DecryptResult, len(resp.GetDecryptedValues()))
for name, result := range resp.GetDecryptedValues() {
if result.GetErrorMessage() != "" {
results[name] = contracts.NewDecryptResultErr(errors.New(result.GetErrorMessage()))
} else {
exposedSecureValue := secretv1beta1.NewExposedSecureValue(result.GetValue())
results[name] = contracts.NewDecryptResultValue(&exposedSecureValue)
}
}
return results, nil
}
@@ -0,0 +1,39 @@
package decrypt
import (
"context"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
)
type LocalDecryptClient struct {
decryptStorage contracts.DecryptStorage
}
var _ contracts.DecryptService = &LocalDecryptClient{}
func NewLocalDecryptClient(decryptStorage contracts.DecryptStorage) (*LocalDecryptClient, error) {
return &LocalDecryptClient{
decryptStorage: decryptStorage,
}, nil
}
func (c *LocalDecryptClient) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) {
results := make(map[string]contracts.DecryptResult, len(names))
for _, name := range names {
exposedSecureValue, err := c.decryptStorage.Decrypt(ctx, xkube.Namespace(namespace), name)
if err != nil {
results[name] = contracts.NewDecryptResultErr(err)
} else {
results[name] = contracts.NewDecryptResultValue(&exposedSecureValue)
}
}
return results, nil
}
func (c *LocalDecryptClient) Close() error {
return nil
}
+63 -18
View File
@@ -1,33 +1,78 @@
package decrypt
import (
"context"
"fmt"
authnlib "github.com/grafana/authlib/authn"
claims "github.com/grafana/authlib/types"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
"github.com/grafana/grafana/pkg/services/authn/grpcutils"
"github.com/grafana/grafana/pkg/setting"
)
type OSSDecryptService struct {
decryptStore contracts.DecryptStorage
}
func ProvideDecryptService(cfg *setting.Cfg, tracer trace.Tracer, decryptStorage contracts.DecryptStorage) (contracts.DecryptService, error) {
switch cfg.SecretsManagement.DecryptServerType {
case "grpc":
grpcClientConfig := grpcutils.ReadGrpcClientConfig(cfg)
func ProvideDecryptService(decryptStore contracts.DecryptStorage) contracts.DecryptService {
return &OSSDecryptService{
decryptStore: decryptStore,
}
}
if cfg.SecretsManagement.DecryptServerAddress == "" {
return nil, fmt.Errorf("decrypt_server_address is required when decrypt_server_type is grpc")
}
func (d *OSSDecryptService) Decrypt(ctx context.Context, namespace string, names ...string) (map[string]contracts.DecryptResult, error) {
results := make(map[string]contracts.DecryptResult, len(names))
if grpcClientConfig.Token == "" || grpcClientConfig.TokenExchangeURL == "" {
return nil, fmt.Errorf("grpc_client_authentication.token and grpc_client_authentication.token_exchange_url are required when secrets_manager.decrypt_server_type is grpc")
}
for _, name := range names {
exposedSecureValue, err := d.decryptStore.Decrypt(ctx, xkube.Namespace(namespace), name)
nsInfo, err := claims.ParseNamespace(grpcClientConfig.TokenNamespace)
if err != nil {
results[name] = contracts.NewDecryptResultErr(err)
} else {
results[name] = contracts.NewDecryptResultValue(&exposedSecureValue)
return nil, fmt.Errorf("failed to parse token namespace %v: %w", grpcClientConfig.TokenNamespace, err)
}
if nsInfo.OrgID < 1 {
return nil, fmt.Errorf("invalid token namepsace %v", grpcClientConfig.TokenNamespace)
}
tokenExchangeClient, err := authnlib.NewTokenExchangeClient(authnlib.TokenExchangeConfig{
Token: grpcClientConfig.Token,
TokenExchangeURL: grpcClientConfig.TokenExchangeURL,
})
if err != nil {
return nil, fmt.Errorf("failed to create token exchange client: %w", err)
}
tlsConfig := readTLSFromConfig(cfg)
client, err := NewGRPCDecryptClientWithTLS(tokenExchangeClient, tracer, grpcClientConfig.TokenNamespace, cfg.SecretsManagement.DecryptServerAddress, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to create grpc decrypt client: %w", err)
}
return client, nil
case "local", "":
return NewLocalDecryptClient(decryptStorage)
}
return nil, fmt.Errorf("unsupported storage type: %s", cfg.SecretsManagement.DecryptServerType)
}
func readTLSFromConfig(cfg *setting.Cfg) TLSConfig {
if !cfg.SecretsManagement.DecryptServerUseTLS {
return TLSConfig{
UseTLS: false,
InsecureSkipVerify: true,
}
}
return results, nil
apiServer := cfg.SectionWithEnvOverrides("grafana-apiserver")
return TLSConfig{
UseTLS: true,
CertFile: apiServer.Key("proxy_client_cert_file").MustString(""),
KeyFile: apiServer.Key("proxy_client_key_file").MustString(""),
CAFile: apiServer.Key("apiservice_ca_bundle_file").MustString(""),
ServerName: cfg.SecretsManagement.DecryptServerTLSServerName,
InsecureSkipVerify: cfg.SecretsManagement.DecryptServerTLSSkipVerify,
}
}
+227 -16
View File
@@ -2,34 +2,52 @@ package decrypt
import (
"context"
"crypto/tls"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
decryptv1beta1 "github.com/grafana/grafana/apps/secret/decrypt/v1beta1"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
"github.com/madflojo/testcerts"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
"golang.org/x/net/nettest"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
"github.com/grafana/grafana/pkg/services/authn/clients"
"github.com/grafana/grafana/pkg/setting"
)
func TestDecryptService(t *testing.T) {
t.Parallel()
ctx := context.Background()
tracer := noop.NewTracerProvider().Tracer("test")
t.Run("when there are only errors from the storage, the service returns them in the map", func(t *testing.T) {
t.Parallel()
mockErr := errors.New("mock error")
mockStorage := &MockDecryptStorage{}
mockStorage := &mockDecryptStorage{}
mockStorage.On("Decrypt", mock.Anything, mock.Anything, mock.Anything).Return(secretv1beta1.ExposedSecureValue(""), mockErr)
decryptedValuesResp := map[string]contracts.DecryptResult{
"secure-value-1": contracts.NewDecryptResultErr(mockErr),
}
decryptService := &OSSDecryptService{
decryptStore: mockStorage,
}
cfg := setting.NewCfg()
cfg.SecretsManagement.DecryptServerType = "local"
decryptService, err := ProvideDecryptService(cfg, tracer, mockStorage)
require.NoError(t, err)
resp, err := decryptService.Decrypt(ctx, "default", "secure-value-1")
require.NotNil(t, resp)
@@ -40,7 +58,7 @@ func TestDecryptService(t *testing.T) {
t.Run("when there is no error from the storage, it returns a map of the decrypted values", func(t *testing.T) {
t.Parallel()
mockStorage := &MockDecryptStorage{}
mockStorage := &mockDecryptStorage{}
// Set up the mock to return a different value for each name in the test
exposedSecureValue1 := secretv1beta1.NewExposedSecureValue("value1")
exposedSecureValue2 := secretv1beta1.NewExposedSecureValue("value2")
@@ -54,9 +72,11 @@ func TestDecryptService(t *testing.T) {
"secure-value-2": contracts.NewDecryptResultValue(&exposedSecureValue2),
}
decryptService := &OSSDecryptService{
decryptStore: mockStorage,
}
cfg := setting.NewCfg()
cfg.SecretsManagement.DecryptServerType = "local"
decryptService, err := ProvideDecryptService(cfg, tracer, mockStorage)
require.NoError(t, err)
resp, err := decryptService.Decrypt(ctx, "default", "secure-value-1", "secure-value-2")
require.NotNil(t, resp)
@@ -68,7 +88,7 @@ func TestDecryptService(t *testing.T) {
t.Parallel()
mockErr := errors.New("mock error")
mockStorage := &MockDecryptStorage{}
mockStorage := &mockDecryptStorage{}
exposedSecureValue := secretv1beta1.NewExposedSecureValue("value")
mockStorage.On("Decrypt", mock.Anything, xkube.Namespace("default"), "secure-value-1").
Return(exposedSecureValue, nil)
@@ -80,22 +100,213 @@ func TestDecryptService(t *testing.T) {
"secure-value-2": contracts.NewDecryptResultErr(mockErr),
}
decryptService := &OSSDecryptService{
decryptStore: mockStorage,
}
cfg := setting.NewCfg()
cfg.SecretsManagement.DecryptServerType = "local"
decryptService, err := ProvideDecryptService(cfg, tracer, mockStorage)
require.NoError(t, err)
resp, err := decryptService.Decrypt(ctx, "default", "secure-value-1", "secure-value-2")
require.NotNil(t, resp)
require.NoError(t, err)
require.EqualValues(t, decryptedValuesResp, resp)
})
t.Run("when storage type is unsupported, it returns an error", func(t *testing.T) {
t.Parallel()
cfg := setting.NewCfg()
cfg.SecretsManagement.DecryptServerType = "unsupported"
decryptService, err := ProvideDecryptService(cfg, tracer, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported storage type")
require.Nil(t, decryptService)
})
t.Run("when storage type is grpc but token exchange config is missing, it returns an error", func(t *testing.T) {
t.Parallel()
cfg := setting.NewCfg()
cfg.SecretsManagement.DecryptServerType = "grpc"
cfg.SecretsManagement.DecryptServerAddress = "127.0.0.1:10000"
_, err := ProvideDecryptService(cfg, tracer, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "grpc_client_authentication.token and grpc_client_authentication.token_exchange_url are required")
})
t.Run("when storage type is grpc but storage address is missing, it returns an error", func(t *testing.T) {
t.Parallel()
cfg := setting.NewCfg()
cfg.SecretsManagement.DecryptServerType = "grpc"
_, err := ProvideDecryptService(cfg, tracer, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "decrypt_server_address is required")
})
t.Run("happy path with grpc+tls server with fake toke exchanger and server", func(t *testing.T) {
t.Parallel()
respTokenExchanged := "test-token"
tokenExchangeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := `{
"data": {
"token": "` + respTokenExchanged + `"
}
}`
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(response))
}))
t.Cleanup(tokenExchangeServer.Close)
// Set up gRPC Server with TLS
listener, err := nettest.NewLocalListener("tcp")
require.NoError(t, err)
certPaths := createX509TestDir(t)
serverCert, err := tls.LoadX509KeyPair(certPaths.serverCert, certPaths.serverKey)
require.NoError(t, err)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.NoClientCert,
InsecureSkipVerify: false,
ServerName: "localhost",
}
grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig)))
t.Cleanup(grpcServer.Stop)
decryptServer := &mockDecryptServer{}
decryptServer.On("DecryptSecureValues", mock.Anything, mock.Anything).Return(
&decryptv1beta1.SecureValueDecryptResponseCollection{
DecryptedValues: map[string]*decryptv1beta1.Result{
"secure-value-1": {
Result: &decryptv1beta1.Result_Value{Value: "decrypted-value-1"},
},
},
},
nil,
)
decryptv1beta1.RegisterSecureValueDecrypterServer(grpcServer, decryptServer)
go func() {
_ = grpcServer.Serve(listener)
<-t.Context().Done()
}()
// Populate configuration with gRPC+TLS options and mock token exchanger
grafanaSvcIdentity := "svc-identity-decrypter"
namespace := "stacks-1234"
cfg := setting.NewCfg()
cfg.SecretsManagement.DecryptServerType = "grpc"
cfg.SecretsManagement.DecryptServerAddress = listener.Addr().String()
cfg.SecretsManagement.DecryptGrafanaServiceName = grafanaSvcIdentity
cfg.SecretsManagement.DecryptServerUseTLS = true
cfg.SecretsManagement.DecryptServerTLSServerName = "localhost"
cfg.SecretsManagement.DecryptServerTLSSkipVerify = false
grpcClientAuth := cfg.Raw.Section("grpc_client_authentication")
_, err = grpcClientAuth.NewKey("token", "test-token")
require.NoError(t, err)
_, err = grpcClientAuth.NewKey("token_exchange_url", tokenExchangeServer.URL)
require.NoError(t, err)
_, err = grpcClientAuth.NewKey("token_namespace", namespace)
require.NoError(t, err)
apiServer := cfg.Raw.Section("grafana-apiserver")
_, err = apiServer.NewKey("proxy_client_cert_file", certPaths.clientCert)
require.NoError(t, err)
_, err = apiServer.NewKey("proxy_client_key_file", certPaths.clientKey)
require.NoError(t, err)
_, err = apiServer.NewKey("apiservice_ca_bundle_file", certPaths.ca)
require.NoError(t, err)
// Create and test decryption, using the mock grpc server as we dont test the business logic here
decryptService, err := ProvideDecryptService(cfg, tracer, nil)
require.NoError(t, err)
require.NotNil(t, decryptService)
t.Cleanup(func() { require.NoError(t, decryptService.Close()) })
svcIdentity := "provsysoning-test"
authCtx := identity.WithServiceIdentityContext(ctx, 1, identity.WithServiceIdentityName(svcIdentity))
result, err := decryptService.Decrypt(authCtx, namespace, "secure-value-1")
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result, 1)
require.NotEmpty(t, result["secure-value-1"])
requestContext := decryptServer.Calls[0].Arguments[0].(context.Context)
md, ok := metadata.FromIncomingContext(requestContext)
require.True(t, ok)
require.NotEmpty(t, md)
require.Equal(t, svcIdentity, md[strings.ToLower(contracts.HeaderGrafanaServiceIdentityName)][0])
require.Equal(t, respTokenExchanged, md[strings.ToLower(clients.ExtJWTAuthenticationHeaderName)][0])
})
}
type MockDecryptStorage struct {
type mockDecryptStorage struct {
mock.Mock
}
func (m *MockDecryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, name string) (secretv1beta1.ExposedSecureValue, error) {
func (m *mockDecryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, name string) (secretv1beta1.ExposedSecureValue, error) {
args := m.Called(ctx, namespace, name)
return args.Get(0).(secretv1beta1.ExposedSecureValue), args.Error(1)
}
type mockDecryptServer struct {
mock.Mock
}
var _ decryptv1beta1.SecureValueDecrypterServer = (*mockDecryptServer)(nil)
func (m *mockDecryptServer) DecryptSecureValues(ctx context.Context, req *decryptv1beta1.SecureValueDecryptRequest) (*decryptv1beta1.SecureValueDecryptResponseCollection, error) {
args := m.Called(ctx, req)
return args.Get(0).(*decryptv1beta1.SecureValueDecryptResponseCollection), args.Error(1)
}
type certPaths struct {
clientCert string
clientKey string
serverCert string
serverKey string
ca string
}
func createX509TestDir(t *testing.T) certPaths {
t.Helper()
tmpDir := t.TempDir()
ca := testcerts.NewCA()
caCertFile, _, err := ca.ToTempFile(tmpDir)
require.NoError(t, err)
serverKp, err := ca.NewKeyPair("localhost")
require.NoError(t, err)
serverCertFile, serverKeyFile, err := serverKp.ToTempFile(tmpDir)
require.NoError(t, err)
clientKp, err := ca.NewKeyPair()
require.NoError(t, err)
clientCertFile, clientKeyFile, err := clientKp.ToTempFile(tmpDir)
require.NoError(t, err)
return certPaths{
clientCert: clientCertFile.Name(),
clientKey: clientKeyFile.Name(),
serverCert: serverCertFile.Name(),
serverKey: serverKeyFile.Name(),
ca: caCertFile.Name(),
}
}
@@ -59,6 +59,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut {
}
tracer := noop.NewTracerProvider().Tracer("test")
testDB := sqlstore.NewTestStore(t, sqlstore.WithMigrator(migrator.New()))
database := database.ProvideDatabase(testDB, tracer)
@@ -77,11 +78,11 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut {
})
defaultKey := "SdlklWklckeLS"
cfg := &setting.Cfg{
SecretsManagement: setting.SecretsManagerSettings{
CurrentEncryptionProvider: "secret_key.v1",
ConfiguredKMSProviders: map[string]map[string]string{"secret_key.v1": {"secret_key": defaultKey}},
},
cfg := setting.NewCfg()
cfg.SecretsManagement = setting.SecretsManagerSettings{
DecryptServerType: "local",
CurrentEncryptionProvider: "secret_key.v1",
ConfiguredKMSProviders: map[string]map[string]string{"secret_key.v1": {"secret_key": defaultKey}},
}
store, err := encryptionstorage.ProvideDataKeyStorage(database, tracer, nil)
require.NoError(t, err)
@@ -126,7 +127,13 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut {
decryptStorage, err := metadata.ProvideDecryptStorage(tracer, keeperService, keeperMetadataStorage, secureValueMetadataStorage, decryptAuthorizer, nil)
require.NoError(t, err)
decryptService := decrypt.ProvideDecryptService(decryptStorage)
testCfg := setting.NewCfg()
testCfg.SecretsManagement = setting.SecretsManagerSettings{
DecryptServerType: "local",
}
decryptService, err := decrypt.ProvideDecryptService(testCfg, tracer, decryptStorage)
require.NoError(t, err)
return Sut{
SecureValueService: secureValueService,
+10 -4
View File
@@ -788,8 +788,11 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
if err != nil {
return nil, err
}
v3 := decrypt.ProvideDecryptService(decryptStorage)
repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3)
v3, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage)
if err != nil {
return nil, err
}
repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3, cfg)
webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, repositorySecrets, factory, renderingService, resourceClient, eventualRestConfigProvider)
v4 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder)
apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, repositorySecrets, tracingService, v4)
@@ -1346,8 +1349,11 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface {
if err != nil {
return nil, err
}
v3 := decrypt.ProvideDecryptService(decryptStorage)
repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3)
v3, err := decrypt.ProvideDecryptService(cfg, tracer, decryptStorage)
if err != nil {
return nil, err
}
repositorySecrets := secrets.ProvideRepositorySecrets(featureToggles, secretsService, secureValueClient, v3, cfg)
webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, repositorySecrets, factory, renderingService, resourceClient, eventualRestConfigProvider)
v4 := extras.ProvideProvisioningOSSExtras(webhookExtraBuilder)
apiBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, factory, accessClient, legacyMigrator, dualwriteService, usageStats, repositorySecrets, tracingService, v4)
+14
View File
@@ -15,12 +15,26 @@ type SecretsManagerSettings struct {
// ConfiguredKMSProviders is a map of KMS providers found in the config file. The keys are in the format of <provider>.<keyName>, and the values are a map of the properties in that section
// In OSS, the provider type can only be "secret_key". In Enterprise, it can additionally be one of: "aws_kms", "azure_keyvault", "google_kms", "hashicorp_vault"
ConfiguredKMSProviders map[string]map[string]string
DecryptServerType string // "local" or "grpc"
DecryptServerUseTLS bool // Applicable for decrypt_server_type=grpc. Whether to use TLS for the decrypt server
DecryptServerTLSSkipVerify bool // Applicable for decrypt_server_type=grpc. Whether to skip TLS verification for the decrypt server
DecryptServerTLSServerName string // Applicable for decrypt_server_type=grpc. Server name to use for TLS verification
DecryptServerAddress string // Applicable for decrypt_server_type=grpc. Address for external secrets server
DecryptGrafanaServiceName string // Service name to use for background grafana decryption
}
func (cfg *Cfg) readSecretsManagerSettings() {
secretsMgmt := cfg.Raw.Section("secrets_manager")
cfg.SecretsManagement.CurrentEncryptionProvider = secretsMgmt.Key("encryption_provider").MustString(MisconfiguredProvider)
cfg.SecretsManagement.DecryptServerType = valueAsString(secretsMgmt, "decrypt_server_type", "local")
cfg.SecretsManagement.DecryptServerUseTLS = secretsMgmt.Key("decrypt_server_use_tls").MustBool(false)
cfg.SecretsManagement.DecryptServerTLSSkipVerify = secretsMgmt.Key("decrypt_server_tls_skip_verify").MustBool(false)
cfg.SecretsManagement.DecryptServerTLSServerName = valueAsString(secretsMgmt, "decrypt_server_tls_server_name", "")
cfg.SecretsManagement.DecryptServerAddress = valueAsString(secretsMgmt, "decrypt_server_address", "")
cfg.SecretsManagement.DecryptGrafanaServiceName = valueAsString(secretsMgmt, "decrypt_grafana_service_name", "")
// Extract available KMS providers from configuration sections
providers := make(map[string]map[string]string)
for _, section := range cfg.Raw.Sections() {
+24 -4
View File
@@ -7,12 +7,13 @@ import (
"time"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/metadata"
"github.com/grafana/grafana-app-sdk/logging"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
@@ -65,14 +66,33 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace,
defer func() {
span.SetAttributes(attribute.String("decrypter.identity", decrypterIdentity))
args := []any{
"namespace", namespace.String(),
"secret_name", name,
"decrypter_identity", decrypterIdentity,
}
// The service identity used for decryption is always what is from the signed token, but if the request is
// coming from grafana, the service identity will be grafana, but the request metadata will contain
// additional service identity information (such as coming from the provisioning service in grafana).
// we do this for auditing purposes.
if md, ok := metadata.FromIncomingContext(ctx); ok {
if svcIdentities := md.Get(contracts.HeaderGrafanaServiceIdentityName); len(svcIdentities) > 0 {
args = append(args, "grafana_decrypter_identity", svcIdentities[0])
span.SetAttributes(attribute.String("grafana_decrypter.identity", svcIdentities[0]))
}
}
if decryptErr == nil {
logging.FromContext(ctx).Info("Audit log:", "operation", "decrypt_secret_success", "namespace", namespace, "secret_name", name, "decrypter_identity", decrypterIdentity)
args = append(args, "operation", "decrypt_secret_success")
} 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)
args = append(args, "operation", "decrypt_secret_error", "error", decryptErr.Error())
}
logging.FromContext(ctx).Info("Secrets Audit Log", args...)
success := decryptErr == nil
s.metrics.DecryptDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds())
s.metrics.DecryptRequestCount.WithLabelValues(strconv.FormatBool(success)).Inc()
@@ -6,7 +6,9 @@ import (
"github.com/grafana/authlib/authn"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/stretchr/testify/require"
grpcmetadata "google.golang.org/grpc/metadata"
"k8s.io/utils/ptr"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
@@ -248,10 +250,67 @@ func TestIntegrationDecrypt(t *testing.T) {
require.Empty(t, exposed)
})
// TODO: add more tests for keeper failure scenarios, lets see how the async work will change this though.
t.Run("happy path with grpc metadata in request, also record the metadata as part of the service identity", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
tokenSvcIdentity := "svc"
stSvcIdentity := "st-svc"
// Create auth context with proper permissions that match the decrypters
authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, tokenSvcIdentity, types.TypeUser)
// Needs to be incoming because we are pretending we received the metadata from a gRPC request
ctx = grpcmetadata.NewIncomingContext(authCtx, grpcmetadata.New(map[string]string{
contracts.HeaderGrafanaServiceIdentityName: stSvcIdentity,
}))
// Setup service
sut := testutils.Setup(t)
// Create a secure value
spec := secretv1beta1.SecureValueSpec{
Description: "description",
Decrypters: []string{tokenSvcIdentity},
Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")),
}
sv := &secretv1beta1.SecureValue{Spec: spec}
sv.Name = "sv-test"
sv.Namespace = "default"
_, err := sut.CreateSv(ctx, testutils.CreateSvWithSv(sv))
require.NoError(t, err)
fakeLogger := &mockLogger{}
loggerCtx := logging.Context(ctx, fakeLogger)
exposed, err := sut.DecryptStorage.Decrypt(loggerCtx, "default", "sv-test")
require.NoError(t, err)
require.NotEmpty(t, exposed)
require.Equal(t, "value", exposed.DangerouslyExposeAndConsumeValue())
require.Len(t, fakeLogger.InfoArgs, 1)
args := fakeLogger.InfoArgs[0]
require.Contains(t, args, "grafana_decrypter_identity")
require.Contains(t, args, "decrypter_identity")
for i, arg := range args {
if arg == "grafana_decrypter_identity" {
require.Equal(t, stSvcIdentity, args[i+1].(string))
}
if arg == "decrypter_identity" {
require.Equal(t, tokenSvcIdentity, args[i+1].(string))
}
}
})
}
func createAuthContext(ctx context.Context, namespace string, permissions []string, svc string, identityType types.IdentityType) context.Context {
ctx = logging.Context(ctx, logging.DefaultLogger)
requester := &identity.StaticRequester{
Type: identityType,
Namespace: namespace,
@@ -269,3 +328,18 @@ func createAuthContext(ctx context.Context, namespace string, permissions []stri
return types.WithAuthInfo(ctx, requester)
}
type mockLogger struct {
logging.Logger
InfoMsgs []string
InfoArgs [][]any
}
func (m *mockLogger) Info(msg string, args ...any) {
m.InfoMsgs = append(m.InfoMsgs, msg)
m.InfoArgs = append(m.InfoArgs, args)
}
func (m *mockLogger) WithContext(ctx context.Context) logging.Logger {
return m
}