Authorization bypass / Information disclosure
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)
}