Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs

This commit is contained in:
Ryan McKinley
2025-07-08 18:00:28 -07:00
74 changed files with 2267 additions and 1229 deletions
+150 -76
View File
@@ -245,78 +245,136 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
defer s.reloadMutex.RUnlock()
s.log.Debug("Getting user info")
toCheck := make([]*UserInfoJson, 0, 2)
if tokenData := s.extractFromToken(token); tokenData != nil {
toCheck = append(toCheck, tokenData)
// 1. Collect user info data from various sources
dataSources := s.collectUserInfoData(ctx, client, token)
// 2. Build user info from collected data
userInfo, externalOrgs, err := s.buildUserInfo(dataSources)
if err != nil {
return nil, err
}
// 3. Post-process user info
err = s.postProcessUserInfo(ctx, client, userInfo, externalOrgs)
if err != nil {
return nil, err
}
// 4. Validate user access
err = s.validateUserAccess(ctx, client, userInfo)
if err != nil {
return nil, err
}
s.log.Debug("User info result", "result", userInfo)
return userInfo, nil
}
// collectUserInfoData gathers user information from ID token, API, and access token
func (s *SocialGenericOAuth) collectUserInfoData(ctx context.Context, client *http.Client, token *oauth2.Token) []*UserInfoJson {
dataSources := make([]*UserInfoJson, 0, 3)
if idTokenData := s.extractFromIDToken(token); idTokenData != nil {
dataSources = append(dataSources, idTokenData)
}
if apiData := s.extractFromAPI(ctx, client); apiData != nil {
toCheck = append(toCheck, apiData)
dataSources = append(dataSources, apiData)
}
if accessTokenData := s.extractFromAccessToken(token); accessTokenData != nil {
dataSources = append(dataSources, accessTokenData)
}
return dataSources
}
// buildUserInfo constructs BasicUserInfo from collected data sources
func (s *SocialGenericOAuth) buildUserInfo(dataSources []*UserInfoJson) (*social.BasicUserInfo, []string, error) {
userInfo := &social.BasicUserInfo{}
var externalOrgs []string
for _, data := range toCheck {
for _, data := range dataSources {
s.log.Debug("Processing external user info", "source", data.source, "data", data)
if userInfo.Id == "" {
userInfo.Id = data.Sub
s.extractBasicUserFields(userInfo, data)
if err := s.extractRoleAndOrgs(userInfo, &externalOrgs, data); err != nil {
return nil, nil, err
}
if userInfo.Name == "" {
userInfo.Name = s.extractUserName(data)
}
s.extractUserGroups(userInfo, data)
}
if userInfo.Login == "" {
userInfo.Login = s.extractLogin(data)
}
return userInfo, externalOrgs, nil
}
if userInfo.Email == "" {
userInfo.Email = s.extractEmail(data)
if userInfo.Email != "" {
s.log.Debug("Set user info email from extracted email", "email", userInfo.Email)
}
}
// extractBasicUserFields extracts basic user fields (ID, Name, Login, Email) from data
func (s *SocialGenericOAuth) extractBasicUserFields(userInfo *social.BasicUserInfo, data *UserInfoJson) {
if userInfo.Id == "" {
userInfo.Id = data.Sub
}
if userInfo.Role == "" && !s.info.SkipOrgRoleSync {
role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{})
if err != nil {
s.log.Warn("Failed to extract role", "err", err)
} else {
userInfo.Role = role
if s.info.AllowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin
}
}
}
if userInfo.Name == "" {
userInfo.Name = s.extractUserName(data)
}
if len(externalOrgs) == 0 && !s.info.SkipOrgRoleSync {
var err error
externalOrgs, err = s.extractOrgs(data.rawJSON)
if err != nil {
s.log.Warn("Failed to extract orgs", "err", err)
return nil, err
}
}
if userInfo.Login == "" {
userInfo.Login = s.extractLogin(data)
}
if len(userInfo.Groups) == 0 {
groups, err := s.extractGroups(data)
if err != nil {
s.log.Warn("Failed to extract groups", "err", err)
} else if len(groups) > 0 {
s.log.Debug("Setting user info groups from extracted groups")
userInfo.Groups = groups
if userInfo.Email == "" {
userInfo.Email = s.extractEmail(data)
if userInfo.Email != "" {
s.log.Debug("Set user info email from extracted email", "email", userInfo.Email)
}
}
}
// extractRoleAndOrgs extracts role and organization information from data
func (s *SocialGenericOAuth) extractRoleAndOrgs(userInfo *social.BasicUserInfo, externalOrgs *[]string, data *UserInfoJson) error {
if userInfo.Role == "" && !s.info.SkipOrgRoleSync {
role, grafanaAdmin, err := s.extractRoleAndAdminOptional(data.rawJSON, []string{})
if err != nil {
s.log.Warn("Failed to extract role", "err", err)
} else {
userInfo.Role = role
if s.info.AllowAssignGrafanaAdmin {
userInfo.IsGrafanaAdmin = &grafanaAdmin
}
}
}
if len(*externalOrgs) == 0 && !s.info.SkipOrgRoleSync {
orgs, err := s.extractOrgs(data.rawJSON)
if err != nil {
s.log.Warn("Failed to extract orgs", "err", err)
return err
}
*externalOrgs = orgs
}
return nil
}
// extractUserGroups extracts group information from data
func (s *SocialGenericOAuth) extractUserGroups(userInfo *social.BasicUserInfo, data *UserInfoJson) {
if len(userInfo.Groups) == 0 {
groups, err := s.extractGroups(data)
if err != nil {
s.log.Warn("Failed to extract groups", "err", err)
} else if len(groups) > 0 {
s.log.Debug("Setting user info groups from extracted groups")
userInfo.Groups = groups
}
}
}
// postProcessUserInfo handles post-processing of user info (org roles, private email, etc.)
func (s *SocialGenericOAuth) postProcessUserInfo(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo, externalOrgs []string) error {
if !s.info.SkipOrgRoleSync {
userInfo.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, userInfo.Role)
if s.info.RoleAttributeStrict && len(userInfo.OrgRoles) == 0 {
// If no roles are found and role_attribute_strict is set, return an error.
// The s.info.RoleAttributeStrict is necessary, because there is a case when len(userInfo.OrgRoles) == 0,
// but strict role mapping is not enabled (when getAllOrgs fails).
return nil, errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data")
return errRoleAttributeStrictViolation.Errorf("could not evaluate any valid roles using IdP provided data")
}
}
@@ -325,11 +383,11 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
}
if s.canFetchPrivateEmail(userInfo) {
var err error
userInfo.Email, err = s.fetchPrivateEmail(ctx, client)
email, err := s.fetchPrivateEmail(ctx, client)
if err != nil {
return nil, err
return err
}
userInfo.Email = email
s.log.Debug("Setting email from fetched private email", "email", userInfo.Email)
}
@@ -338,28 +396,32 @@ func (s *SocialGenericOAuth) UserInfo(ctx context.Context, client *http.Client,
userInfo.Login = userInfo.Email
}
return nil
}
// validateUserAccess validates user access based on team, organization, and group membership
func (s *SocialGenericOAuth) validateUserAccess(ctx context.Context, client *http.Client, userInfo *social.BasicUserInfo) error {
if !s.isTeamMember(ctx, client) {
return nil, &SocialError{"User not a member of one of the required teams"}
return &SocialError{"User not a member of one of the required teams"}
}
if !s.isOrganizationMember(ctx, client) {
return nil, &SocialError{"User not a member of one of the required organizations"}
return &SocialError{"User not a member of one of the required organizations"}
}
if !s.isGroupMember(userInfo.Groups) {
return nil, errMissingGroupMembership
return errMissingGroupMembership
}
s.log.Debug("User info result", "result", userInfo)
return userInfo, nil
return nil
}
func (s *SocialGenericOAuth) canFetchPrivateEmail(userinfo *social.BasicUserInfo) bool {
return s.info.ApiUrl != "" && userinfo.Email == ""
}
func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson {
s.log.Debug("Extracting user info from OAuth token")
func (s *SocialGenericOAuth) extractFromIDToken(token *oauth2.Token) *UserInfoJson {
s.log.Debug("Extracting user info from OAuth ID token")
idTokenAttribute := "id_token"
if s.idTokenAttributeName != "" {
@@ -373,21 +435,44 @@ func (s *SocialGenericOAuth) extractFromToken(token *oauth2.Token) *UserInfoJson
return nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
rawJSON, err := s.retrieveRawJWTPayload(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", token))
s.log.Warn("Error retrieving id_token payload", "error", err, "token", fmt.Sprintf("%+v", token))
return nil
}
return s.parseUserInfoFromJSON(rawJSON, "id_token")
}
func (s *SocialGenericOAuth) extractFromAccessToken(token *oauth2.Token) *UserInfoJson {
s.log.Debug("Extracting user info from OAuth access token")
accessToken := token.AccessToken
if accessToken == "" {
s.log.Debug("No access token found")
return nil
}
rawJSON, err := s.retrieveRawJWTPayload(accessToken)
if err != nil {
s.log.Warn("Error retrieving access token payload", "error", err)
return nil
}
return s.parseUserInfoFromJSON(rawJSON, "access_token")
}
// parseUserInfoFromJSON is a helper method to parse UserInfoJson from raw JSON and source
func (s *SocialGenericOAuth) parseUserInfoFromJSON(rawJSON []byte, source string) *UserInfoJson {
var data UserInfoJson
if err := json.Unmarshal(rawJSON, &data); err != nil {
s.log.Error("Error decoding id_token JSON", "raw_json", string(rawJSON), "error", err)
s.log.Error("Error decoding user info JSON", "raw_json", string(rawJSON), "error", err, "source", source)
return nil
}
data.rawJSON = rawJSON
data.source = "token"
s.log.Debug("Received id_token", "raw_json", string(data.rawJSON), "data", data.String())
data.source = source
s.log.Debug("Parsed user info from JSON", "raw_json", string(rawJSON), "data", data.String(), "source", source)
return &data
}
@@ -404,18 +489,7 @@ func (s *SocialGenericOAuth) extractFromAPI(ctx context.Context, client *http.Cl
return nil
}
rawJSON := rawUserInfoResponse.Body
var data UserInfoJson
if err := json.Unmarshal(rawJSON, &data); err != nil {
s.log.Error("Error decoding user info response", "raw_json", rawJSON, "error", err)
return nil
}
data.rawJSON = rawJSON
data.source = "API"
s.log.Debug("Received user info response from API", "raw_json", string(rawJSON), "data", data.String())
return &data
return s.parseUserInfoFromJSON(rawUserInfoResponse.Body, "API")
}
func (s *SocialGenericOAuth) extractEmail(data *UserInfoJson) string {
@@ -31,6 +31,7 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) {
AllowAssignGrafanaAdmin bool
ResponseBody any
OAuth2Extra any
AccessToken string
Setup func(*orgtest.FakeOrgService)
RoleAttributePath string
RoleAttributeStrict bool
@@ -440,6 +441,62 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) {
ExpectedEmail: "john.doe@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleViewer},
},
// Access Token Test Cases
{
Name: "Given a valid access token with role, no ID token, no API response, use access token",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" }
RoleAttributePath: "role",
ExpectedEmail: "access.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor},
},
{
Name: "Given a valid access token with org roles, no ID token, no API response, use access token",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiVmlld2VyIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20iLCJpbmZvIjp7InJvbGVzIjpbImFjY2Vzcy1kZXYiLCJhY2Nlc3Mtb3BzIl19fQ.g8-mNJQDL9CJWgRTFdKBRRKbsHZfFhJrzPYQGXfxGIE", // { "role": "Viewer", "email": "access.token@example.com", "info": { "roles": [ "access-dev", "access-ops" ] }}
RoleAttributePath: "role",
OrgAttributePath: "info.roles",
OrgMapping: []string{"access-dev:org_dev:Admin", "access-ops:org_engineering:Editor"},
ExpectedEmail: "access.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{4: org.RoleAdmin, 5: org.RoleEditor},
},
{
Name: "Given a valid access token and ID token, prefer ID token",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{
// { "role": "Admin", "email": "id.token@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQWRtaW4iLCJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.T8wcoOOPQ_av9VsOFoYJZGNFGJgG0d3LPDvtxvgODkU",
},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.oVEMSJVqBwrGXOcwGgXL_8J-CZhgFVPjXXSqzPJQ5JU", // { "role": "Editor", "email": "access.token@example.com" }
RoleAttributePath: "role",
ExpectedEmail: "id.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
{
Name: "Given a valid access token with no email, ID token with no role, API response with no data, merge",
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{
// { "email": "id.token@example.com" }
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImlkLnRva2VuQGV4YW1wbGUuY29tIn0.k5GwPcZvGe2BE_jgwN0ntz0nz4KlYhEd0hRRLApkTJ4",
},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiRWRpdG9yIn0.gfnKWZKNFNqrILhHFzabBVEWnJJIZBmQSBwLPCHhLUY", // { "role": "Editor" }
RoleAttributePath: "role",
ExpectedEmail: "id.token@example.com",
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleEditor},
},
{
Name: "Given a valid access token with GrafanaAdmin role and AssignGrafanaAdmin enabled",
AllowAssignGrafanaAdmin: true,
ResponseBody: map[string]any{},
OAuth2Extra: map[string]any{},
AccessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiR3JhZmFuYUFkbWluIiwiZW1haWwiOiJhY2Nlc3MudG9rZW5AZXhhbXBsZS5jb20ifQ.fJPjMgZW9bOYXOLgOUekNQmNrVbUNhU1iqQJwqFWzUY", // { "role": "GrafanaAdmin", "email": "access.token@example.com" }
RoleAttributePath: "role",
ExpectedEmail: "access.token@example.com",
ExpectedGrafanaAdmin: trueBoolPtr(),
ExpectedOrgRoles: map[int64]org.RoleType{2: org.RoleAdmin},
},
}
cfg := &setting.Cfg{
@@ -479,8 +536,9 @@ func TestUserInfoSearchesForEmailAndOrgRoles(t *testing.T) {
require.NoError(t, err)
}))
provider.info.ApiUrl = ts.URL
staticToken := oauth2.Token{
AccessToken: "",
AccessToken: tc.AccessToken,
TokenType: "",
RefreshToken: "",
Expiry: time.Now(),
@@ -853,7 +911,7 @@ func TestPayloadCompression(t *testing.T) {
}
token := staticToken.WithExtra(test.OAuth2Extra)
userInfo := provider.extractFromToken(token)
userInfo := provider.extractFromIDToken(token)
if test.ExpectedEmail == "" {
require.Nil(t, userInfo, "Testing case %q", test.Name)
+1 -1
View File
@@ -275,7 +275,7 @@ func (s *SocialGitlab) extractFromToken(ctx context.Context, client *http.Client
return nil, nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
rawJSON, err := s.retrieveRawJWTPayload(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken))
return nil, nil
+1 -1
View File
@@ -236,7 +236,7 @@ func (s *SocialGoogle) extractFromToken(_ context.Context, _ *http.Client, token
return nil, nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
rawJSON, err := s.retrieveRawJWTPayload(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken))
return nil, nil
+5 -5
View File
@@ -196,21 +196,21 @@ func (s *SocialBase) isGroupMember(groups []string) bool {
return false
}
func (s *SocialBase) retrieveRawIDToken(idToken any) ([]byte, error) {
tokenString, ok := idToken.(string)
func (s *SocialBase) retrieveRawJWTPayload(token any) ([]byte, error) {
tokenString, ok := token.(string)
if !ok {
return nil, fmt.Errorf("id_token is not a string: %v", idToken)
return nil, fmt.Errorf("token is not a string: %v", token)
}
jwtRegexp := regexp.MustCompile("^([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)[.]([-_a-zA-Z0-9=]+)$")
matched := jwtRegexp.FindStringSubmatch(tokenString)
if matched == nil {
return nil, fmt.Errorf("id_token is not in JWT format: %s", tokenString)
return nil, fmt.Errorf("token is not in JWT format: %s", tokenString)
}
rawJSON, err := base64.RawURLEncoding.DecodeString(matched[2])
if err != nil {
return nil, fmt.Errorf("error base64 decoding id_token: %w", err)
return nil, fmt.Errorf("error base64 decoding token payload: %w", err)
}
headerBytes, err := base64.RawURLEncoding.DecodeString(matched[1])
-3
View File
@@ -114,9 +114,6 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
if !s.cfg.JWTAuth.SkipOrgRoleSync {
role, grafanaAdmin := s.extractRoleAndAdmin(claims)
if err != nil {
s.log.Warn("Failed to extract role", "err", err)
}
if s.cfg.JWTAuth.AllowAssignGrafanaAdmin {
id.IsGrafanaAdmin = &grafanaAdmin
+6 -4
View File
@@ -861,7 +861,7 @@ func TestIntegrationFolderGetPermissions(t *testing.T) {
}
// TestFoldersCreateAPIEndpointK8S is the counterpart of pkg/api/folder_test.go TestFoldersCreateAPIEndpoint
func TestFoldersCreateAPIEndpointK8S(t *testing.T) {
func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
@@ -902,7 +902,7 @@ func TestFoldersCreateAPIEndpointK8S(t *testing.T) {
description: "folder creation fails without permissions to create a folder",
input: folderWithoutParentInput,
expectedCode: http.StatusForbidden,
expectedMessage: dashboards.ErrFolderAccessDenied.Error(),
expectedMessage: fmt.Sprintf("You'll need additional permissions to perform this action. Permissions needed: %s", "folders:create"),
permissions: []resourcepermissions.SetResourcePermissionCommand{},
},
{
@@ -1022,7 +1022,7 @@ func testDescription(description string, expectedErr error) string {
}
// There are no counterpart of TestFoldersGetAPIEndpointK8S in pkg/api/folder_test.go
func TestFoldersGetAPIEndpointK8S(t *testing.T) {
func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
@@ -1062,6 +1062,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) {
expectedOutput: []dtos.FolderSearchHit{
{UID: "foo", Title: "Folder 1"},
{UID: "qux", Title: "Folder 3"},
{UID: folder.SharedWithMeFolder.UID, Title: folder.SharedWithMeFolder.Title},
},
permissions: folderReadAndCreatePermission,
},
@@ -1107,7 +1108,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) {
}
// test on all dualwriter modes
for mode := 1; mode <= 4; mode++ {
for mode := 0; mode <= 4; mode++ {
for _, tc := range tcs {
t.Run(fmt.Sprintf("Mode: %d, %s", mode, tc.description), func(t *testing.T) {
modeDw := grafanarest.DualWriterMode(mode)
@@ -1123,6 +1124,7 @@ func TestFoldersGetAPIEndpointK8S(t *testing.T) {
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
featuremgmt.FlagUnifiedStorageSearch,
featuremgmt.FlagKubernetesClientDashboardsFolders,
},
})