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
+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
}