Authorization bypass / Information disclosure

HIGH
grafana/grafana
Commit: 6732d0c7464a
Affected: Grafana <= 12.4.0 (pre-fix)
2026-06-26 11:53 UTC

Description

The commit enforces service identity authorization when resolving display metadata (createdBy/updatedBy) by obtaining the requester and using a service-scoped context for the user lookup. This prevents callers without proper permissions from triggering user display-name resolution via ListByIdOrUID, reducing potential information exposure (who created/updated an object) and potential privilege misuse. Prior to this change, display-name resolution could be performed with a less-privileged context, allowing leakage of user identities or names through internal metadata enrichment.

Proof of Concept

PoC (high-level, actionable steps): 1) Environment: Grafana instance running 12.4.0 or earlier, with an Organization containing multiple users (e.g., admin and regular viewer). 2) Roles: Attacker uses a viewer (or similarly restricted) account that does not have elevated permissions to read user details across the org, but can still query objects that include createdBy/updatedBy metadata. 3) Action: Authenticated API call that triggers metadata enrichment for an object (examples could include listing folders, dashboards, or other resources that include createdBy/updatedBy fields). The call should be something like: - GET https://grafana.example/api/folders?uid=<FOLDER_UID> - (or the equivalent endpoint that returns object metadata enriched with CreatedBy/UpdatedBy information) 4) Expected pre-fix (vulnerable) behavior: - The server resolves createdBy/updatedBy display names using the user service without requiring the requester to have users:read-level permissions for the org. The response includes display names such as "Created by Admin" or similar, exposing user identities or names to a restricted user. - Example response (before fix): { "id":"folder-123", "name":"Reports", "createdBy":"user-42", "createdByDisplayName":"Admin User", "updatedBy":"user-77", "updatedByDisplayName":"Senior Admin" } 5) Expected post-fix behavior: - The code now obtains the requester via service identity and uses a service-scoped context when calling ListByIdOrUID. If the requester lacks proper authorization, the display-name resolution is restricted or rejected, preventing leakage of display names for non-service identities. - The API response may redact or omit the display-name fields unless the requester is granted the appropriate rights (users:read) for the org. 6) Reproduce with a test: - Use a non-service account to request metadata that would trigger display-name resolution. - Observe that displayName fields are either omitted or require elevated permissions, aligning with the new authorization check introduced by the commit.

Commit Details

Author: Mihai Doarna

Date: 2026-06-26 11:24 UTC

Message:

IAM: Remove error based legacy fallback from ListByIdOrUID (#127158) * remove error based legacy fallback from user service * bring back the legacy fallback * remove fallback from ListByIdOrUID only * fix test by using a service identity * fix test

Triage Assessment

Vulnerability Type: Authorization bypass / Information disclosure

Confidence: HIGH

Reasoning:

The commit enforces service identity authorization when resolving display metadata (createdBy/updatedBy) by obtaining the requester and using a service-scoped context for the user lookup. This prevents callers without proper permissions from triggering user display-name resolution, reducing potential information exposure and privilege misuse.

Verification Assessment

Vulnerability Type: Authorization bypass / Information disclosure

Confidence: HIGH

Affected Versions: Grafana <= 12.4.0 (pre-fix)

Code Diff

diff --git a/pkg/services/folder/folderimpl/conversions.go b/pkg/services/folder/folderimpl/conversions.go index 3ab3aaaa7e20b..85ad99d01a26a 100644 --- a/pkg/services/folder/folderimpl/conversions.go +++ b/pkg/services/folder/folderimpl/conversions.go @@ -9,6 +9,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/services/dashboards" @@ -144,7 +145,16 @@ func (ss *FolderUnifiedStoreImpl) getFolderIdentifiers(ctx context.Context, iden return nil, nil } - users, err := ss.userService.ListByIdOrUID(ctx, userUIDs, userIds) + // Resolving createdBy/updatedBy display info is internal metadata enrichment, not a + // resource the caller explicitly requested. Authorize it against the service identity so + // callers without users:read (e.g. viewers) can still resolve display names. + requester, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + svcCtx := identity.WithServiceIdentityContext(ctx, requester.GetOrgID()) + + users, err := ss.userService.ListByIdOrUID(svcCtx, userUIDs, userIds) if err != nil { return nil, err } diff --git a/pkg/services/folder/folderimpl/conversions_test.go b/pkg/services/folder/folderimpl/conversions_test.go index f1506bf533011..b4dd70087749c 100644 --- a/pkg/services/folder/folderimpl/conversions_test.go +++ b/pkg/services/folder/folderimpl/conversions_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/user" @@ -65,7 +66,8 @@ func TestFolderConversions(t *testing.T) { fs := ProvideUnifiedStore(nil, fake, tracer, setting.NewCfg()) - converted, err := fs.UnstructuredToLegacyFolder(context.Background(), input) + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + converted, err := fs.UnstructuredToLegacyFolder(ctx, input) require.NoError(t, err) require.Equal(t, 1, len(fake.ListUsersByIdOrUidCalls)) // only one call to the user service require.Equal(t, usertest.ListUsersByIdOrUidCall{Uids: []string{"useruid"}, Ids: []int64{2}}, fake.ListUsersByIdOrUidCalls[0]) @@ -271,7 +273,8 @@ func TestFolderListConversions(t *testing.T) { fs := ProvideUnifiedStore(nil, fake, tracer, setting.NewCfg()) - converted, err := fs.UnstructuredToLegacyFolderList(context.Background(), input) + ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1}) + converted, err := fs.UnstructuredToLegacyFolderList(ctx, input) require.NoError(t, err) require.Equal(t, 1, len(fake.ListUsersByIdOrUidCalls)) // only one call to the user service require.ElementsMatch(t, []string{"uuuuuuuuuuuuuu", "iiiiiiiiiiiiii", "jjjjjjjjjjjjjj"}, fake.ListUsersByIdOrUidCalls[0].Uids) diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index ab4ca34c41856..22433bd397686 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -123,21 +123,10 @@ func (s *Service) GetByUID(ctx context.Context, cmd *user.GetUserByUIDQuery) (*u } func (s *Service) ListByIdOrUID(ctx context.Context, uids []string, ids []int64) ([]*user.User, error) { - ctx, span := s.tracer.Start(ctx, "user.wrapper.ListByIdOrUID") - defer span.End() - - ctxLogger := s.logger.FromContext(ctx) - if s.isKubernetesUserServiceEnabled(ctx) && !s.shouldFallbackToLegacy(ctx) { - result, err := s.k8sService.ListByIdOrUID(s.k8sCtxWithIdentity(ctx), uids, ids) - if err == nil { - span.SetAttributes(attribute.Bool("fallback_to_legacy", false)) - return result, nil - } - ctxLogger.Warn("k8s ListByIdOrUID failed, falling back to legacy", "err", err) + return s.k8sService.ListByIdOrUID(s.k8sCtxWithIdentity(ctx), uids, ids) } - span.SetAttributes(attribute.Bool("fallback_to_legacy", true)) return s.legacyService.ListByIdOrUID(ctx, uids, ids) }
← Back to Alerts View on GitHub →