IAM: Add search for teams in app platform (#113503)

* add legacy search (wip)

* fix search field name

* implement team search endpoint

* generate openapi spec

* generate endpoints for frontend

* minor fixes

* fix issues found while testing

* add more fields to search result

* add basic unit tests

* add more unit tests

* improve getColumns() func in legacy search

* configure search endpoint in team.cue

* add team search handler

* add the searchTeams endpoint to manifest.cue

* make gofmt

* update openapi spec

* generate frontend endpoints

* remove unused field

* move fields defiitions to separate builder

* fix legacy search

* fix unit tests

* fix unit test

* address feedback

* fix unit test

* update openapi specs

* yarn generate-apis

* add missing unit tests
This commit is contained in:
Mihai Doarna
2025-12-08 15:02:59 +02:00
committed by GitHub
parent 5bd73f3264
commit b2e1b257b3
27 changed files with 1957 additions and 15 deletions
+82
View File
@@ -0,0 +1,82 @@
package search
import (
"fmt"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
)
func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0alpha1.TeamSearchResults, error) {
if result == nil {
return v0alpha1.TeamSearchResults{}, nil
} else if result.Error != nil {
return v0alpha1.TeamSearchResults{}, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details)
} else if result.Results == nil {
return v0alpha1.TeamSearchResults{}, nil
}
titleIDX := -1
emailIDX := -1
provisionedIDX := -1
externalUIDIDX := -1
for i, v := range result.Results.Columns {
if v == nil {
continue
}
switch v.Name {
case resource.SEARCH_FIELD_TITLE:
titleIDX = i
case builders.TEAM_SEARCH_EMAIL:
emailIDX = i
case builders.TEAM_SEARCH_PROVISIONED:
provisionedIDX = i
case builders.TEAM_SEARCH_EXTERNAL_UID:
externalUIDIDX = i
}
}
sr := v0alpha1.TeamSearchResults{
Offset: offset,
TotalHits: result.TotalHits,
QueryCost: result.QueryCost,
MaxScore: result.MaxScore,
Hits: make([]v0alpha1.TeamHit, len(result.Results.Rows)),
}
for i, row := range result.Results.Rows {
if len(row.Cells) != len(result.Results.Columns) {
return v0alpha1.TeamSearchResults{}, fmt.Errorf("error parsing team search response: mismatch number of columns and cells")
}
hit := &v0alpha1.TeamHit{
Name: row.Key.Name,
}
if titleIDX >= 0 && row.Cells[titleIDX] != nil {
hit.Title = string(row.Cells[titleIDX])
} else {
hit.Title = "(no title)"
}
if emailIDX >= 0 && row.Cells[emailIDX] != nil {
hit.Email = string(row.Cells[emailIDX])
}
if provisionedIDX >= 0 && row.Cells[provisionedIDX] != nil {
hit.Provisioned = string(row.Cells[provisionedIDX]) == "true"
}
if externalUIDIDX >= 0 && row.Cells[externalUIDIDX] != nil {
hit.ExternalUID = string(row.Cells[externalUIDIDX])
}
sr.Hits[i] = *hit
}
return sr, nil
}
+227
View File
@@ -0,0 +1,227 @@
package search
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestParseResults(t *testing.T) {
t.Run("should parse results", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "provisioned",
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
},
{
Name: "externalUID",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
[]byte("Team 1"),
[]byte("team1@example.com"),
[]byte("true"),
[]byte("team1-uid"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Len(t, results.Hits, 1)
require.Equal(t, "Team 1", results.Hits[0].Title)
require.Equal(t, "team1@example.com", results.Hits[0].Email)
require.True(t, results.Hits[0].Provisioned)
require.Equal(t, "team1-uid", results.Hits[0].ExternalUID)
})
t.Run("should handle nil result", func(t *testing.T) {
results, err := ParseResults(nil, 0)
require.NoError(t, err)
require.Empty(t, results.Hits)
require.Zero(t, results.TotalHits)
})
t.Run("should handle nil Results", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: nil,
TotalHits: 0,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Empty(t, results.Hits)
require.Zero(t, results.TotalHits)
})
t.Run("should handle nil Results.Rows", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
},
Rows: nil,
},
TotalHits: 0,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Empty(t, results.Hits)
require.Zero(t, results.TotalHits)
})
t.Run("should return error for mismatched number of columns and cells", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "provisioned",
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
[]byte("Team 1"),
[]byte("team1@example.com"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.Error(t, err)
require.Contains(t, err.Error(), "mismatch number of columns and cells")
require.Empty(t, results.Hits)
})
t.Run("should return error for error response", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Error: &resourcepb.ErrorResult{
Code: 500,
Message: "Internal server error",
Details: &resourcepb.ErrorDetails{
Name: "test-resource",
},
},
}
results, err := ParseResults(searchResp, 0)
require.Error(t, err)
require.Contains(t, err.Error(), "500 error searching: Internal server error")
require.Empty(t, results.Hits)
})
t.Run("should use (no title) fallback when title cell is nil", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
nil, // title cell is nil
[]byte("team1@example.com"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Len(t, results.Hits, 1)
require.Equal(t, "(no title)", results.Hits[0].Title)
require.Equal(t, "team1@example.com", results.Hits[0].Email)
})
t.Run("should use (no title) fallback when title column is missing", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "provisioned",
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
[]byte("team1@example.com"),
[]byte("true"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Len(t, results.Hits, 1)
require.Equal(t, "(no title)", results.Hits[0].Title)
require.Equal(t, "team1@example.com", results.Hits[0].Email)
require.True(t, results.Hits[0].Provisioned)
})
}
+9 -8
View File
@@ -7,13 +7,14 @@ import (
)
type FakeService struct {
ExpectedTeam team.Team
ExpectedIsMember bool
ExpectedIsAdmin bool
ExpectedTeamDTO *team.TeamDTO
ExpectedTeamsByUser []*team.TeamDTO
ExpectedMembers []*team.TeamMemberDTO
ExpectedError error
ExpectedTeam team.Team
ExpectedIsMember bool
ExpectedIsAdmin bool
ExpectedTeamDTO *team.TeamDTO
ExpectedTeamsByUser []*team.TeamDTO
ExpectedMembers []*team.TeamMemberDTO
ExpectedSearchTeamsResult team.SearchTeamQueryResult
ExpectedError error
}
func NewFakeService() *FakeService {
@@ -39,7 +40,7 @@ func (s *FakeService) DeleteTeam(ctx context.Context, cmd *team.DeleteTeamComman
}
func (s *FakeService) SearchTeams(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error) {
return team.SearchTeamQueryResult{}, s.ExpectedError
return s.ExpectedSearchTeamsResult, s.ExpectedError
}
func (s *FakeService) GetTeamByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) {