Deserialization/Type Confusion in Kubernetes client usage (unstructured -> typed)

MEDIUM
grafana/grafana
Commit: a4c7393e1ba5
Affected: < 12.4.0
2026-04-23 09:25 UTC

Description

The commit replaces usage of a dynamic Kubernetes client (unstructured path) in UserK8sService with a typed client generated via the Grafana App SDK (ClientGenerator). Previously, the code constructed an unstructured Kubernetes object and used a dynamic client to Create it, which can be prone to deserialization/type-confusion issues when handling Kubernetes resources. The change moves to a strongly-typed IAM v0alpha1.User object and uses a typed client, enforcing schema validation and reducing the risk of crafted or malformed payloads being misinterpreted during user creation. This addresses a potential deserialization-related vulnerability associated with unstructured Kubernetes resources and type conversion.

Commit Details

Author: Mihai Doarna

Date: 2026-04-23 08:29 UTC

Message:

IAM: Use a typed client in UserK8sService (#123232) * use a typed client in UserK8sService * replace generator with nil in tests * revert test changes * add BaseCLIWireSet for enterpise wire

Triage Assessment

Vulnerability Type: Deserialization vulnerabilities

Confidence: MEDIUM

Reasoning:

The commit migrates from a dynamic/unstructured Kubernetes client usage to a typed client (via a ClientGenerator). This reduces the risk of deserialization/type confusion issues when interacting with Kubernetes resources and improves safety against malformed or crafted objects, which are common vectors for remote or internal resource manipulation. The change eliminates the use of dynamic/unstructured handling in UserK8sService, which is a known area for potential security pitfalls, and enforces stricter typing when creating/reading Kubernetes User resources.

Verification Assessment

Vulnerability Type: Deserialization/Type Confusion in Kubernetes client usage (unstructured -> typed)

Confidence: MEDIUM

Affected Versions: < 12.4.0

Code Diff

diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 01f1bcff7def1..8b1f668717978 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -442,7 +442,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - userimplService, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamimplService, cacheService, tracingService, quotaService, bundleregistryService, eventualRestConfigProvider) + clientGenerator := apiserver.ProvideClientGenerator(eventualRestConfigProvider) + userimplService, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamimplService, cacheService, tracingService, quotaService, bundleregistryService, clientGenerator) if err != nil { return nil, err } @@ -768,7 +769,6 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api apiAPI := api3.ProvideApi(cfg, featureToggles, starService, eventualRestConfigProvider) anonUserLimitValidatorImpl := validator2.ProvideAnonUserLimitValidator() anonDeviceService := anonimpl.ProvideAnonymousDeviceService(usageStats, authnService, sqlStore, cfg, orgService, serverLockService, accessControl, routeRegisterImpl, anonUserLimitValidatorImpl) - clientGenerator := apiserver.ProvideClientGenerator(eventualRestConfigProvider) signingkeysimplService, err := signingkeysimpl.ProvideEmbeddedSigningKeysService(sqlStore, secretsService, remoteCache, routeRegisterImpl) if err != nil { return nil, err @@ -1141,7 +1141,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - userimplService, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamimplService, cacheService, tracingService, quotaService, bundleregistryService, eventualRestConfigProvider) + clientGenerator := apiserver.ProvideClientGenerator(eventualRestConfigProvider) + userimplService, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamimplService, cacheService, tracingService, quotaService, bundleregistryService, clientGenerator) if err != nil { return nil, err } @@ -1469,7 +1470,6 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac apiAPI := api3.ProvideApi(cfg, featureToggles, starService, eventualRestConfigProvider) anonUserLimitValidatorImpl := validator2.ProvideAnonUserLimitValidator() anonDeviceService := anonimpl.ProvideAnonymousDeviceService(usageStats, authnService, sqlStore, cfg, orgService, serverLockService, accessControl, routeRegisterImpl, anonUserLimitValidatorImpl) - clientGenerator := apiserver.ProvideClientGenerator(eventualRestConfigProvider) signingkeysimplService, err := signingkeysimpl.ProvideEmbeddedSigningKeysService(sqlStore, secretsService, remoteCache, routeRegisterImpl) if err != nil { return nil, err @@ -1761,7 +1761,8 @@ func InitializeForCLI(ctx context.Context, cfg *setting.Cfg) (Runner, error) { return Runner{}, err } cacheService := localcache.ProvideService() - userimplService, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamimplService, cacheService, tracingService, quotaService, bundleregistryService, eventualRestConfigProvider) + clientGenerator := apiserver.ProvideClientGenerator(eventualRestConfigProvider) + userimplService, err := userimpl.ProvideService(sqlStore, orgService, cfg, teamimplService, cacheService, tracingService, quotaService, bundleregistryService, clientGenerator) if err != nil { return Runner{}, err } diff --git a/pkg/services/apiserver/wireset.go b/pkg/services/apiserver/wireset.go index daaf337bc2339..393b775019e03 100644 --- a/pkg/services/apiserver/wireset.go +++ b/pkg/services/apiserver/wireset.go @@ -16,3 +16,13 @@ var WireSet = wire.NewSet( wire.Bind(new(builder.APIRegistrar), new(*service)), ProvideClientGenerator, ) + +// BaseCLIWireSet provides the minimal set needed by CLI runners that don't start +// the full apiserver: the concrete rest config provider (bound to both interfaces) +// and the typed ClientGenerator — without ProvideService or its builder metrics. +var BaseCLIWireSet = wire.NewSet( + ProvideEventualRestConfigProvider, + wire.Bind(new(RestConfigProvider), new(*eventualRestConfigProvider)), + wire.Bind(new(DirectRestConfigProvider), new(*eventualRestConfigProvider)), + ProvideClientGenerator, +) diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index c17f5ffb06aad..a11d7b8f7705d 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "github.com/grafana/grafana-app-sdk/resource" "github.com/open-feature/go-sdk/openfeature" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -14,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" @@ -31,6 +31,7 @@ type Service struct { openFeatureClient *openfeature.Client logger log.Logger tracer tracing.Tracer + cfg *setting.Cfg } var _ user.Service = (*Service)(nil) @@ -41,13 +42,13 @@ func ProvideService(db db.DB, teamService team.Service, cacheService *localcache.CacheService, tracer tracing.Tracer, quotaService quota.Service, bundleRegistry supportbundles.Service, - configProvider apiserver.DirectRestConfigProvider) (*Service, error) { + clientGenerator resource.ClientGenerator) (*Service, error) { legacyService, err := NewLegacyService(db, orgService, cfg, teamService, cacheService, tracer, quotaService, bundleRegistry) if err != nil { return nil, err } - k8sService := userk8s.NewUserK8sService(log.New("user.k8s"), cfg, configProvider, tracer) + k8sService := userk8s.NewUserK8sService(log.New("user.k8s"), cfg, clientGenerator, tracer) return &Service{ legacyService: legacyService, @@ -55,6 +56,7 @@ func ProvideService(db db.DB, openFeatureClient: openfeature.NewDefaultClient(), logger: log.New("user"), tracer: tracer, + cfg: cfg, }, nil } @@ -186,19 +188,17 @@ func (s *Service) GetSignedInUser(ctx context.Context, cmd *user.GetSignedInUser ctxLogger := s.logger.FromContext(ctx) if s.isKubernetesUserServiceEnabled(ctx) { - if hasOrgID(ctx) || cmd.OrgID > 0 { - result, err := s.k8sService.GetSignedInUser(ctx, cmd) - if err == nil { - span.SetAttributes(attribute.Bool("fallback_to_legacy", false)) - return result, nil - } - // Fall back to legacy is needed on the following cases: - // - When the user is not found in k8s. It happens with integration tests where the user is created in the legacy store but not in k8s. - // - When there is no request context (e.g. calls from the k8s API server handler). - ctxLogger.Warn("k8s GetSignedInUser failed, falling back to legacy", "userID", cmd.UserID, "err", err) - } else { - ctxLogger.Warn("no orgID in context, falling back to legacy", "method", "GetSignedInUser") + k8sCmd := *cmd + if !hasOrgID(ctx) && k8sCmd.OrgID == 0 { + k8sCmd.OrgID = s.cfg.DefaultOrgID() + } + + result, err := s.k8sService.GetSignedInUser(ctx, &k8sCmd) + if err == nil { + span.SetAttributes(attribute.Bool("fallback_to_legacy", false)) + return result, nil } + ctxLogger.Warn("k8s GetSignedInUser failed, falling back to legacy", "userID", cmd.UserID, "err", err) } span.SetAttributes(attribute.Bool("fallback_to_legacy", true)) diff --git a/pkg/services/user/userk8s/user.go b/pkg/services/user/userk8s/user.go index 9f762dc3a67ea..88fdf8b874208 100644 --- a/pkg/services/user/userk8s/user.go +++ b/pkg/services/user/userk8s/user.go @@ -7,71 +7,48 @@ import ( "strings" "time" + "github.com/grafana/grafana-app-sdk/resource" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/fields" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) -var userGVR = schema.GroupVersionResource{ - Group: iamv0alpha1.APIGroup, - Version: iamv0alpha1.APIVersion, - Resource: "users", -} - type UserK8sService struct { logger log.Logger namespaceMapper request.NamespaceMapper - configProvider apiserver.DirectRestConfigProvider + clientGenerator resource.ClientGenerator config *setting.Cfg tracer tracing.Tracer } var _ user.Service = (*UserK8sService)(nil) -func NewUserK8sService(logger log.Logger, cfg *setting.Cfg, configProvider apiserver.DirectRestConfigProvider, tracer tracing.Tracer) *UserK8sService { +func NewUserK8sService(logger log.Logger, cfg *setting.Cfg, clientGenerator resource.ClientGenerator, tracer tracing.Tracer) *UserK8sService { return &UserK8sService{ logger: logger, namespaceMapper: request.GetNamespaceMapper(cfg), - configProvider: configProvider, + clientGenerator: clientGenerator, config: cfg, tracer: tracer, } } -func (s *UserK8sService) getClient(ctx context.Context, namespace string) (dynamic.ResourceInterface, error) { - if s.configProvider == nil { - return nil, errors.New("config provider not initialized") +func (s *UserK8sService) getUserClient() (*iamv0alpha1.UserClient, error) { + if s.clientGenerator == nil { + return nil, errors.New("client generator not initialized") } - - reqCtx := contexthandler.FromContext(ctx) - if reqCtx == nil { - return nil, errors.New("no request context") - } - - dyn, err := dynamic.NewForConfig(s.configProvider.GetDirectRestConfig(reqCtx)) - if err != nil { - return nil, err - } - - return dyn.Resource(userGVR).Namespace(namespace), nil + return iamv0alpha1.NewUserClientFromGenerator(s.clientGenerator) } func (s *UserK8sService) Create(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { @@ -91,7 +68,7 @@ func (s *UserK8sService) Create(ctx context.Context, cmd *user.CreateUserCommand namespace := s.namespaceMapper(orgID) span.SetAttributes(attribute.Int64("orgID", orgID)) - client, err := s.getClient(ctx, namespace) + client, err := s.getUserClient() if err != nil { ctxLogger.Error("failed to get k8s client", "namespace", namespace, "err", err) return nil, err @@ -111,11 +88,7 @@ func (s *UserK8sService) Create(ctx context.Context, cmd *user.CreateUserCommand cmd.Email = cmd.Login } - k8sUser := iamv0alpha1.User{ - TypeMeta: metav1.TypeMeta{ - APIVersion: iamv0alpha1.GroupVersion.Identifier(), - Kind: "User", - }, + k8sUser := &iamv0alpha1.User{ ObjectMeta: metav1.ObjectMeta{ Name: uid, Namespace: namespace, @@ -132,24 +105,14 @@ func (s *UserK8sService) Create(ctx context.Context, cmd *user.CreateUserCommand }, } - unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&k8sUser) - if err != nil { - return nil, err - } - - result, err := client.Create(ctx, &unstructured.Unstructured{Object: unstructuredObj}, metav1.CreateOptions{}) + created, err := client.Create(ctx, k8sUser, resource.CreateOptions{}) if err != nil { ctxLogger.Error("k8s user create failed", "namespace", namespace, "orgID", orgID, "login", cmd.Login, "err", err) span.RecordError(err) return nil, err } - var created iamv0alpha1.User - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(result.Object, &created); err != nil { - return nil, err - } - - return toUser(&created, orgID), nil + return toUser(created, orgID), nil } func (s *UserK8sService) CreateServiceAccount(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { @@ -179,7 +142,7 @@ func (s *UserK8sService) GetByID(ctx context.Context, cmd *user.GetUserByIDQuery namespace := s.namespaceMapper(orgID) span.SetAttributes(attribute.Int64("orgID", orgID)) - client, err := s.getClient(ctx, namespace) + client, err := s.getUserClient() if err != nil { ctxLogger.Error("failed to get k8s client", "namespace", namespace, "err", err) span.RecordError(err) @@ -228,14 +191,14 @@ func (s *UserK8sService) GetByLogin(ctx context.Context, cmd *user.GetUserByLogi namespace := s.namespaceMapper(orgID) span.SetAttributes(attribute.Int64("orgID", orgID)) - client, err := s.getClient(ctx, namespace) + client, err := s.getUserClient() if err != nil { ctxLogger.Error("failed to get k8s client", "namespace", namespace, "err", err) return nil, err } if strings.Contains(loginOrEmail, "@") { - u, err := s.getByFieldSelector(ctx, ctxLogger, client, "spec.email", loginOrEmail, orgID) + u, err := s.getByFieldSelector(ctx, ctxLogger, client, "spec.email", loginOrEmail, namespace, orgID) if err != nil && !errors.Is(err, user.ErrUserNotFound) { span.RecordError(err) return nil, err @@ -245,7 +208,7 @@ func (s *UserK8sService) GetByLogin(ctx context.Context, cmd *user.GetUserByLogi } } - u, err := s.getByFieldSelector(ctx, ctxLogger, client, "spec.login", loginOrEmail, orgID) + u, err := s.getByFieldSelector(ctx, ctxLogger, client, "spec.login", loginOrEmail, namespace, orgID) if err != nil { span.RecordError(err) return nil, err @@ -271,13 +234,13 @@ func (s *UserK8sService) GetByEmail(ctx context.Context, cmd *user.GetUserByEmai namespace := s.namespaceMapper(orgID) span.SetAttributes(attribute.Int64("orgID", orgID)) - client, err := s.getClient(ctx, namespace) + client, err := s.getUserClient() if err != nil { ctxLogger.Error("failed to get k8s client", "namespace", namespace, "err", err) return nil, err } - u, err := s.getByFieldSelector(ctx, ctxLogger, client, "spec.email", strings.ToLower(cmd.Email), orgID) + u, err := s.getByFieldSelector(ctx, ctxLogger, client, "spec.email", strings.ToLower(cmd.Email), namespace, orgID) if err != nil { span.RecordError(err) return nil, err @@ -305,7 +268,7 @@ func (s *UserK8sService) Update(ctx context.Context, cmd *user.UpdateUserCommand namespace := s.namespaceMapper(orgID) span.SetAt ... [truncated]
← Back to Alerts View on GitHub →