From b2e1b257b3559a6eae42f89d791533d5efd87de7 Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Mon, 8 Dec 2025 15:02:59 +0200 Subject: [PATCH] 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 --- apps/iam/kinds/manifest.cue | 28 ++ ...etsearchteams_request_params_object_gen.go | 33 ++ ...getsearchteams_request_params_types_gen.go | 12 + .../getsearchteams_response_body_types_gen.go | 33 ++ ...etsearchteams_response_object_types_gen.go | 37 +++ apps/iam/pkg/apis/iam/v0alpha1/register.go | 1 + apps/iam/pkg/apis/iam/v0alpha1/team_search.go | 35 +++ .../pkg/apis/iam/v0alpha1/zz_openapi_gen.go | 177 +++++++++++ apps/iam/pkg/apis/iam_manifest.go | 138 ++++++++- .../rtkq/iam/v0alpha1/endpoints.gen.ts | 36 +++ pkg/registry/apis/iam/authorizer.go | 10 + pkg/registry/apis/iam/models.go | 1 + pkg/registry/apis/iam/register.go | 13 +- pkg/registry/apis/iam/team/legacy_search.go | 141 +++++++++ .../apis/iam/team/legacy_search_test.go | 107 +++++++ pkg/registry/apis/iam/team_search.go | 192 ++++++++++++ pkg/registry/apis/iam/team_search_test.go | 286 ++++++++++++++++++ pkg/server/wire_gen.go | 4 +- pkg/services/team/search/search.go | 82 +++++ pkg/services/team/search/search_test.go | 227 ++++++++++++++ pkg/services/team/teamtest/team.go | 17 +- .../unified/search/builders/document.go | 7 +- .../unified/search/builders/document_test.go | 12 + .../unified/search/builders/team_search.go | 87 ++++++ .../team-with-email-and-external-uid-out.json | 18 ++ .../doc/team-with-email-and-external-uid.json | 13 + .../iam.grafana.app-v0alpha1.json | 225 ++++++++++++++ 27 files changed, 1957 insertions(+), 15 deletions(-) create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/team_search.go create mode 100644 pkg/registry/apis/iam/team/legacy_search.go create mode 100644 pkg/registry/apis/iam/team/legacy_search_test.go create mode 100644 pkg/registry/apis/iam/team_search.go create mode 100644 pkg/registry/apis/iam/team_search_test.go create mode 100644 pkg/services/team/search/search.go create mode 100644 pkg/services/team/search/search_test.go create mode 100644 pkg/storage/unified/search/builders/team_search.go create mode 100644 pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json create mode 100644 pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json diff --git a/apps/iam/kinds/manifest.cue b/apps/iam/kinds/manifest.cue index bc9c3d47ad7..c6f609cbe15 100644 --- a/apps/iam/kinds/manifest.cue +++ b/apps/iam/kinds/manifest.cue @@ -22,4 +22,32 @@ v0alpha1: { serviceaccountv0alpha1, externalGroupMappingv0alpha1 ] + routes: { + namespaced: { + "/searchTeams": { + "GET": { + request: { + query: { + query?: string + } + } + response: { + #TeamHit: { + name: string + title: string + email: string + provisioned: bool + externalUID: string + } + offset: int64 + totalHits: int64 + hits: [...#TeamHit] + queryCost: float64 + maxScore: float64 + } + responseMetadata: objectMeta: false + } + } + } + } } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go new file mode 100644 index 00000000000..76ba539c798 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go @@ -0,0 +1,33 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type GetSearchTeamsRequestParamsObject struct { + metav1.TypeMeta `json:",inline"` + GetSearchTeamsRequestParams `json:",inline"` +} + +func NewGetSearchTeamsRequestParamsObject() *GetSearchTeamsRequestParamsObject { + return &GetSearchTeamsRequestParamsObject{} +} + +func (o *GetSearchTeamsRequestParamsObject) DeepCopyObject() runtime.Object { + dst := NewGetSearchTeamsRequestParamsObject() + o.DeepCopyInto(dst) + return dst +} + +func (o *GetSearchTeamsRequestParamsObject) DeepCopyInto(dst *GetSearchTeamsRequestParamsObject) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + dstGetSearchTeamsRequestParams := GetSearchTeamsRequestParams{} + _ = resource.CopyObjectInto(&dstGetSearchTeamsRequestParams, &o.GetSearchTeamsRequestParams) +} + +var _ runtime.Object = NewGetSearchTeamsRequestParamsObject() diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go new file mode 100644 index 00000000000..ffa5067c41b --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go @@ -0,0 +1,12 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +type GetSearchTeamsRequestParams struct { + Query *string `json:"query,omitempty"` +} + +// NewGetSearchTeamsRequestParams creates a new GetSearchTeamsRequestParams object. +func NewGetSearchTeamsRequestParams() *GetSearchTeamsRequestParams { + return &GetSearchTeamsRequestParams{} +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go new file mode 100644 index 00000000000..9fabe93260d --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go @@ -0,0 +1,33 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit struct { + Name string `json:"name"` + Title string `json:"title"` + Email string `json:"email"` + Provisioned bool `json:"provisioned"` + ExternalUID string `json:"externalUID"` +} + +// NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit creates a new VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit object. +func NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit() *VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit { + return &VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{} +} + +// +k8s:openapi-gen=true +type GetSearchTeamsBody struct { + Offset int64 `json:"offset"` + TotalHits int64 `json:"totalHits"` + Hits []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit `json:"hits"` + QueryCost float64 `json:"queryCost"` + MaxScore float64 `json:"maxScore"` +} + +// NewGetSearchTeamsBody creates a new GetSearchTeamsBody object. +func NewGetSearchTeamsBody() *GetSearchTeamsBody { + return &GetSearchTeamsBody{ + Hits: []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{}, + } +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go new file mode 100644 index 00000000000..685bd7d85be --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go @@ -0,0 +1,37 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// +k8s:openapi-gen=true +type GetSearchTeams struct { + metav1.TypeMeta `json:",inline"` + GetSearchTeamsBody `json:",inline"` +} + +func NewGetSearchTeams() *GetSearchTeams { + return &GetSearchTeams{} +} + +func (t *GetSearchTeamsBody) DeepCopyInto(dst *GetSearchTeamsBody) { + _ = resource.CopyObjectInto(dst, t) +} + +func (o *GetSearchTeams) DeepCopyObject() runtime.Object { + dst := NewGetSearchTeams() + o.DeepCopyInto(dst) + return dst +} + +func (o *GetSearchTeams) DeepCopyInto(dst *GetSearchTeams) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.GetSearchTeamsBody.DeepCopyInto(&dst.GetSearchTeamsBody) +} + +var _ runtime.Object = NewGetSearchTeams() diff --git a/apps/iam/pkg/apis/iam/v0alpha1/register.go b/apps/iam/pkg/apis/iam/v0alpha1/register.go index be70a0e1714..05dbc30e684 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/register.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/register.go @@ -317,6 +317,7 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error { &ServiceAccountList{}, &Team{}, &TeamList{}, + &GetSearchTeams{}, &TeamBinding{}, &TeamBindingList{}, &ExternalGroupMapping{}, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/team_search.go b/apps/iam/pkg/apis/iam/v0alpha1/team_search.go new file mode 100644 index 00000000000..545552a4795 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/team_search.go @@ -0,0 +1,35 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TeamSearchResults struct { + metav1.TypeMeta `json:",inline"` + + // Where the query started from + Offset int64 `json:"offset,omitempty"` + + // The number of matching results + TotalHits int64 `json:"totalHits"` + + // The team body + Hits []TeamHit `json:"hits"` + + // Cost of running the query + QueryCost float64 `json:"queryCost,omitempty"` + + // Max score + MaxScore float64 `json:"maxScore,omitempty"` +} + +// +k8s:deepcopy-gen=true +type TeamHit struct { + Name string `json:"name"` + Title string `json:"title"` + Email string `json:"email,omitempty"` + Provisioned bool `json:"provisioned,omitempty"` + ExternalUID string `json:"externalUID,omitempty"` +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go index e3b024799d7..a41d51e8d70 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go @@ -24,6 +24,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef": schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingTeamRef(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroups": schema_pkg_apis_iam_v0alpha1_GetGroups(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroupsBody": schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": schema_pkg_apis_iam_v0alpha1_GlobalRole(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBinding": schema_pkg_apis_iam_v0alpha1_GlobalRoleBinding(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBindingList": schema_pkg_apis_iam_v0alpha1_GlobalRoleBindingList(ref), @@ -80,6 +82,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus": schema_pkg_apis_iam_v0alpha1_UserStatus(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState": schema_pkg_apis_iam_v0alpha1_UserstatusOperatorState(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref), } } @@ -564,6 +567,132 @@ func schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref common.ReferenceCallback) co } } +func schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "offset": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"), + }, + }, + }, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + }, + Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"}, + } +} + +func schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "offset": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"), + }, + }, + }, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + }, + Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"}, + } +} + func schema_pkg_apis_iam_v0alpha1_GlobalRole(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -2956,3 +3085,51 @@ func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseE }, } } + +func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "email": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "provisioned": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "externalUID": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name", "title", "email", "provisioned", "externalUID"}, + }, + }, + } +} diff --git a/apps/iam/pkg/apis/iam_manifest.go b/apps/iam/pkg/apis/iam_manifest.go index 9fe171525b4..5c27c228ed2 100644 --- a/apps/iam/pkg/apis/iam_manifest.go +++ b/apps/iam/pkg/apis/iam_manifest.go @@ -157,9 +157,139 @@ var appManifestData = app.ManifestData{ }, }, Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, + Namespaced: map[string]spec3.PathProps{ + "/searchTeams": { + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + + OperationId: "getSearchTeams", + + Parameters: []*spec3.Parameter{ + + { + ParameterProps: spec3.ParameterProps{ + Name: "query", + In: "query", + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + }, + }, + + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + Default: &spec3.Response{ + ResponseProps: spec3.ResponseProps{ + Description: "Default OK response", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + }, + }, + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + }, + "kind": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "offset": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + }, + }, + }, + Required: []string{ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore", + "apiVersion", + "kind", + }, + }}, + }}, + }, + }, + }, + }}, + }, + }, + }, + }, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{ + "getSearchTeamsTeamHit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "email": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "externalUID": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "provisioned": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + Required: []string{ + "name", + "title", + "email", + "provisioned", + "externalUID", + }, + }, + }, + }, }, }, }, @@ -196,6 +326,8 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist var customRouteToGoResponseType = map[string]any{ "v0alpha1|Team|groups|GET": v0alpha1.GetGroups{}, + + "v0alpha1||/searchTeams|GET": v0alpha1.GetSearchTeams{}, } // ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. diff --git a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts index 88ce5985631..8b0690f57ba 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts @@ -3,6 +3,7 @@ export const addTagTypes = [ 'API Discovery', 'Display', 'ExternalGroupMapping', + 'Search', 'ServiceAccount', 'SSOSetting', 'TeamBinding', @@ -152,6 +153,18 @@ const injectedRtkApi = api }), invalidatesTags: ['ExternalGroupMapping'], }), + getSearchTeams: build.query({ + query: (queryArg) => ({ + url: `/searchTeams`, + params: { + query: queryArg.query, + limit: queryArg.limit, + offset: queryArg.offset, + page: queryArg.page, + }, + }), + providesTags: ['Search'], + }), listServiceAccount: build.query({ query: (queryArg) => ({ url: `/serviceaccounts`, @@ -862,6 +875,27 @@ export type UpdateExternalGroupMappingApiArg = { force?: boolean; patch: Patch; }; +export type GetSearchTeamsApiResponse = /** status 200 undefined */ { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + hits: any[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + maxScore: number; + offset: number; + queryCost: number; + totalHits: number; +}; +export type GetSearchTeamsApiArg = { + /** team name query string */ + query?: string; + /** limit the number of results */ + limit?: number; + /** start the query at the given offset */ + offset?: number; + /** page number to start from */ + page?: number; +}; export type ListServiceAccountApiResponse = /** status 200 OK */ ServiceAccountList; export type ListServiceAccountApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -2084,6 +2118,8 @@ export const { useReplaceExternalGroupMappingMutation, useDeleteExternalGroupMappingMutation, useUpdateExternalGroupMappingMutation, + useGetSearchTeamsQuery, + useLazyGetSearchTeamsQuery, useListServiceAccountQuery, useLazyListServiceAccountQuery, useCreateServiceAccountMutation, diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 05c8da97c2e..a3997638e1e 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -51,6 +51,9 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer + serviceAuthorizer := gfauthorizer.NewServiceAuthorizer() + resourceAuthorizer["searchTeams"] = serviceAuthorizer + return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer} } @@ -77,6 +80,13 @@ func newLegacyAccessClient(ac accesscontrol.AccessControl, store legacy.LegacyId utils.VerbList: true, }, }, + accesscontrol.ResourceAuthorizerOptions{ + Resource: "searchTeams", + Unchecked: map[string]bool{ + utils.VerbGet: true, + utils.VerbList: true, + }, + }, accesscontrol.ResourceAuthorizerOptions{ Resource: iamv0.TeamResourceInfo.GetName(), Attr: "id", diff --git a/pkg/registry/apis/iam/models.go b/pkg/registry/apis/iam/models.go index 35efb2f2155..b851122ffd9 100644 --- a/pkg/registry/apis/iam/models.go +++ b/pkg/registry/apis/iam/models.go @@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct { dual dualwrite.Service unified resource.ResourceClient userSearchClient resourcepb.ResourceIndexClient + teamSearch *TeamSearchHandler teamGroupsHandler externalgroupmapping.TeamGroupsHandler diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 32e2c9fefef..b9da9e6f2c5 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -29,6 +29,7 @@ import ( grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" iamauthorizer "github.com/grafana/grafana/pkg/registry/apis/iam/authorizer" "github.com/grafana/grafana/pkg/registry/apis/iam/externalgroupmapping" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" @@ -46,7 +47,9 @@ import ( "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" + teamservice "github.com/grafana/grafana/pkg/services/team" legacyuser "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/storage/unified/apistore" @@ -56,6 +59,7 @@ import ( const MaxConcurrentZanzanaWrites = 20 func RegisterAPIService( + cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, ssoService ssosettings.Service, @@ -66,12 +70,14 @@ func RegisterAPIService( reg prometheus.Registerer, coreRolesStorage CoreRoleStorageBackend, rolesStorage RoleStorageBackend, + tracing *tracing.TracingService, roleBindingsStorage RoleBindingStorageBackend, externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend, teamGroupsHandlerImpl externalgroupmapping.TeamGroupsHandler, dual dualwrite.Service, unified resource.ResourceClient, userService legacyuser.Service, + teamService teamservice.Service, ) (*IdentityAccessManagementAPIBuilder, error) { dbProvider := legacysql.NewDatabaseProvider(sql) store := legacy.NewLegacySQLStores(dbProvider) @@ -109,6 +115,7 @@ func RegisterAPIService( unified: unified, userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features), + teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService), unified, features), } apiregistration.RegisterAPI(builder) @@ -502,7 +509,11 @@ func (b *IdentityAccessManagementAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenA func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) - return b.display.GetAPIRoutes(defs) + + routes := b.teamSearch.GetAPIRoutes(defs) + routes.Namespace = append(routes.Namespace, b.display.GetAPIRoutes(defs).Namespace...) + + return routes } func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer { diff --git a/pkg/registry/apis/iam/team/legacy_search.go b/pkg/registry/apis/iam/team/legacy_search.go new file mode 100644 index 00000000000..938062bd9e6 --- /dev/null +++ b/pkg/registry/apis/iam/team/legacy_search.go @@ -0,0 +1,141 @@ +package team + +import ( + "context" + "fmt" + "log/slog" + "math" + "strconv" + + "google.golang.org/grpc" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/team" + res "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" +) + +const ( + TeamResource = "teams" + TeamResourceGroup = "iam.grafana.com" +) + +// LegacyTeamSearchClient is a client for searching for teams in the legacy search engine. +type LegacyTeamSearchClient struct { + resourcepb.ResourceIndexClient + teamService team.Service + log *slog.Logger +} + +// NewLegacyTeamSearchClient creates a new LegacyTeamSearchClient. +func NewLegacyTeamSearchClient(teamService team.Service) *LegacyTeamSearchClient { + return &LegacyTeamSearchClient{ + teamService: teamService, + log: slog.Default().With("logger", "legacy-team-search-client"), + } +} + +// Search searches for teams in the legacy search engine. +func (c *LegacyTeamSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, _ ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + signedInUser, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + if req.Limit > 100 { + req.Limit = 100 + } + if req.Limit <= 0 { + req.Limit = 1 + } + + if req.Page > math.MaxInt32 || req.Page < 0 { + return nil, fmt.Errorf("invalid page number: %d", req.Page) + } + + query := &team.SearchTeamsQuery{ + SignedInUser: signedInUser, + Limit: int(req.Limit), + Page: int(req.Page), + Query: req.Query, + OrgID: signedInUser.GetOrgID(), + } + + res, err := c.teamService.SearchTeams(ctx, query) + if err != nil { + return nil, err + } + + columns := getColumns(req.Fields) + list := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: columns, + }, + } + + namespace := signedInUser.GetNamespace() + + for _, t := range res.Teams { + cells := createCells(t, req.Fields) + list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{ + Key: getResourceKey(t, namespace), + Cells: cells, + }) + } + + list.TotalHits = res.TotalCount + + return list, nil +} + +func getResourceKey(t *team.TeamDTO, namespace string) *resourcepb.ResourceKey { + return &resourcepb.ResourceKey{ + Namespace: namespace, + Group: TeamResourceGroup, + Resource: TeamResource, + Name: t.UID, + } +} + +func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition { + columns := getDefaultColumns() + + for _, field := range fields { + if col, ok := builders.TeamSearchTableColumnDefinitions[field]; ok { + columns = append(columns, col) + } + } + + return columns +} + +func getDefaultColumns() []*resourcepb.ResourceTableColumnDefinition { + searchFields := res.StandardSearchFields() + return []*resourcepb.ResourceTableColumnDefinition{ + searchFields.Field(res.SEARCH_FIELD_NAME), + searchFields.Field(res.SEARCH_FIELD_TITLE), + } +} + +func createCells(t *team.TeamDTO, fields []string) [][]byte { + cells := createDefaultCells(t) + for _, field := range fields { + switch field { + case builders.TEAM_SEARCH_EMAIL: + cells = append(cells, []byte(t.Email)) + case builders.TEAM_SEARCH_PROVISIONED: + cells = append(cells, []byte(strconv.FormatBool(t.IsProvisioned))) + case builders.TEAM_SEARCH_EXTERNAL_UID: + cells = append(cells, []byte(t.ExternalUID)) + } + } + return cells +} + +func createDefaultCells(t *team.TeamDTO) [][]byte { + return [][]byte{ + []byte(t.UID), + []byte(t.Name), + } +} diff --git a/pkg/registry/apis/iam/team/legacy_search_test.go b/pkg/registry/apis/iam/team/legacy_search_test.go new file mode 100644 index 00000000000..f7d70f355c3 --- /dev/null +++ b/pkg/registry/apis/iam/team/legacy_search_test.go @@ -0,0 +1,107 @@ +package team + +import ( + "context" + "errors" + "math" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/team" + "github.com/grafana/grafana/pkg/services/team/teamtest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +func TestLegacyTeamSearchClient_Search(t *testing.T) { + t.Run("search by query", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: 1, + Query: "test", + Fields: []string{"name", "email", "provisioned", "externalUID"}, + } + + mockTeamService.ExpectedSearchTeamsResult = team.SearchTeamQueryResult{ + Teams: []*team.TeamDTO{ + { + UID: "testTeamUID", + Name: "test team", + Email: "test@example.com", + IsProvisioned: true, + ExternalUID: "testExternalUID", + }, + }, + TotalCount: 1, + Page: 1, + PerPage: 10, + } + + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.Equal(t, int64(1), resp.TotalHits) + require.Len(t, resp.Results.Rows, 1) + require.Len(t, resp.Results.Columns, 5) + require.Equal(t, "default", resp.Results.Rows[0].Key.Namespace) + require.Equal(t, "iam.grafana.com", resp.Results.Rows[0].Key.Group) + require.Equal(t, "teams", resp.Results.Rows[0].Key.Resource) + require.Equal(t, "testTeamUID", resp.Results.Rows[0].Key.Name) + require.Equal(t, "testTeamUID", string(resp.Results.Rows[0].Cells[0])) + require.Equal(t, "test team", string(resp.Results.Rows[0].Cells[1])) + require.Equal(t, "test@example.com", string(resp.Results.Rows[0].Cells[2])) + require.Equal(t, "true", string(resp.Results.Rows[0].Cells[3])) + require.Equal(t, "testExternalUID", string(resp.Results.Rows[0].Cells[4])) + }) + + t.Run("returns error if page is negative", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: -1, + } + + _, err := client.Search(ctx, req) + require.Error(t, err) + require.Equal(t, "invalid page number: -1", err.Error()) + }) + + t.Run("returns error if page is greater than math.MaxInt32", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: math.MaxInt32 + 1, + } + + _, err := client.Search(ctx, req) + require.Error(t, err) + require.Equal(t, "invalid page number: 2147483648", err.Error()) + }) + + t.Run("returns error if search teams fails", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: 1, + Query: "test", + } + + mockTeamService.ExpectedError = errors.New("search teams failed") + + _, err := client.Search(ctx, req) + require.Error(t, err) + require.Equal(t, "search teams failed", err.Error()) + }) +} diff --git a/pkg/registry/apis/iam/team_search.go b/pkg/registry/apis/iam/team_search.go new file mode 100644 index 00000000000..786bf9be834 --- /dev/null +++ b/pkg/registry/apis/iam/team_search.go @@ -0,0 +1,192 @@ +package iam + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + + "go.opentelemetry.io/otel/trace" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/featuremgmt" + teamsearch "github.com/grafana/grafana/pkg/services/team/search" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "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" + "github.com/grafana/grafana/pkg/util/errhttp" +) + +type TeamSearchHandler struct { + log log.Logger + client resourcepb.ResourceIndexClient + tracer trace.Tracer + features featuremgmt.FeatureToggles +} + +func NewTeamSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyTeamSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *TeamSearchHandler { + searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0alpha1.TeamResourceInfo.GroupResource(), resourceClient, legacyTeamSearcher, features) + + return &TeamSearchHandler{ + client: searchClient, + log: log.New("grafana-apiserver.teams.search"), + tracer: tracer, + features: features, + } +} + +func (s *TeamSearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes { + searchResults := defs["github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams"].Schema + + return &builder.APIRoutes{ + Namespace: []builder.APIRouteHandler{ + { + Path: "searchTeams", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Tags: []string{"Search"}, + Description: "Team search", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "query", + In: "query", + Description: "team name query string", + Required: false, + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "limit", + In: "query", + Description: "limit the number of results", + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "offset", + In: "query", + Description: "start the query at the given offset", + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "page", + In: "query", + Description: "page number to start from", + Required: false, + Schema: spec.Int64Property(), + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &searchResults, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: s.DoTeamSearch, + }, + }, + } +} + +func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request) { + ctx, span := s.tracer.Start(r.Context(), "team.search") + defer span.End() + + queryParams, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + limit := 50 + offset := 0 + page := 1 + if queryParams.Has("limit") { + limit, _ = strconv.Atoi(queryParams.Get("limit")) + } + if queryParams.Has("offset") { + offset, _ = strconv.Atoi(queryParams.Get("offset")) + if offset > 0 { + page = (offset / limit) + 1 + } + } else if queryParams.Has("page") { + page, _ = strconv.Atoi(queryParams.Get("page")) + offset = (page - 1) * limit + } + + searchRequest := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{}, + Query: queryParams.Get("query"), + Limit: int64(limit), + Offset: int64(offset), + Page: int64(page), + Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false", + Fields: []string{ + builders.TEAM_SEARCH_EMAIL, + builders.TEAM_SEARCH_PROVISIONED, + builders.TEAM_SEARCH_EXTERNAL_UID, + }, + } + + result, err := s.client.Search(ctx, searchRequest) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + searchResults, err := teamsearch.ParseResults(result, searchRequest.Offset) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if err := s.write(w, searchResults); err != nil { + s.log.Error("failed to write team search results", "error", err) + errhttp.Write(ctx, err, w) + return + } +} + +func (s *TeamSearchHandler) write(w http.ResponseWriter, obj any) error { + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(obj) +} diff --git a/pkg/registry/apis/iam/team_search_test.go b/pkg/registry/apis/iam/team_search_test.go new file mode 100644 index 00000000000..ccc1abbf18c --- /dev/null +++ b/pkg/registry/apis/iam/team_search_test.go @@ -0,0 +1,286 @@ +package iam + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +func TestTeamSearchFallback(t *testing.T) { + testCases := []struct { + name string + mode rest.DualWriterMode + expectedLegacyCalled bool + expectedUnifiedCalled bool + }{ + {name: "mode 0", mode: rest.Mode0, expectedLegacyCalled: true, expectedUnifiedCalled: false}, + {name: "mode 1", mode: rest.Mode1, expectedLegacyCalled: true, expectedUnifiedCalled: false}, + {name: "mode 2", mode: rest.Mode2, expectedLegacyCalled: true, expectedUnifiedCalled: false}, + {name: "mode 3", mode: rest.Mode3, expectedLegacyCalled: false, expectedUnifiedCalled: true}, + {name: "mode 4", mode: rest.Mode4, expectedLegacyCalled: false, expectedUnifiedCalled: true}, + {name: "mode 5", mode: rest.Mode5, expectedLegacyCalled: false, expectedUnifiedCalled: true}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "teams.iam.grafana.app": {DualWriterMode: testCase.mode}, + }, + } + dual := dualwrite.ProvideStaticServiceForTests(cfg) + searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/teams/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if !testCase.expectedUnifiedCalled && mockClient.LastSearchRequest != nil { + t.Fatalf("expected Unified Search NOT to be called, but it was") + } + if testCase.expectedLegacyCalled && mockLegacyClient.LastSearchRequest == nil { + t.Fatalf("expected Legacy Search to be called, but it was not") + } + }) + } +} + +func TestTeamSearchHandler(t *testing.T) { + t.Run("search using default team search fields", func(t *testing.T) { + mockClient := &MockClient{} + + features := featuremgmt.WithFeatures() + searchHandler := TeamSearchHandler{ + log: log.New("grafana-apiserver.teams.search"), + client: mockClient, + tracer: tracing.NewNoopTracerService(), + features: features, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/teams/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + expectedFields := []string{"email", "provisioned", "externalUID"} + if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) { + t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields) + } + }) + + t.Run("returns error if search fails", func(t *testing.T) { + mockClient := &MockClient{ + MockError: errors.New("search failed"), + } + + features := featuremgmt.WithFeatures() + searchHandler := TeamSearchHandler{ + log: log.New("grafana-apiserver.teams.search"), + client: mockClient, + tracer: tracing.NewNoopTracerService(), + features: features, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/teams/search?query=test", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected StatusInternalServerError, got %d", rr.Code) + } + }) + + t.Run("should calculate offset and page parameters", func(t *testing.T) { + limit := 50 + for i, tt := range []struct { + offset int + page int + expectedOffset int + expectedPage int + }{ + { + offset: 0, + page: 0, + expectedOffset: 0, + expectedPage: 1, + }, + { + offset: 0, + page: 1, + expectedOffset: 0, + expectedPage: 1, + }, + { + offset: 0, + page: 2, + expectedOffset: 50, + expectedPage: 2, + }, + { + offset: 0, + page: 3, + expectedOffset: 100, + expectedPage: 3, + }, + { + offset: 50, + page: 0, + expectedOffset: 50, + expectedPage: 2, + }, + { + offset: 100, + page: 0, + expectedOffset: 100, + expectedPage: 3, + }, + { + offset: 149, + page: 0, + expectedOffset: 149, + expectedPage: 3, + }, + { + offset: 150, + page: 0, + expectedOffset: 150, + expectedPage: 4, + }, + } { + mockClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "teams.iam.grafana.app": {DualWriterMode: rest.Mode0}, + }, + } + dual := dualwrite.ProvideStaticServiceForTests(cfg) + searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockClient, mockClient, nil) + + rr := httptest.NewRecorder() + endpoint := fmt.Sprintf("/teams/search?limit=%d", limit) + if tt.offset > 0 { + endpoint = fmt.Sprintf("%s&offset=%d", endpoint, tt.offset) + } + if tt.page > 0 { + endpoint = fmt.Sprintf("%s&page=%d", endpoint, tt.page) + } + + req := httptest.NewRequest("GET", endpoint, nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Team Search to be called, but it was not") + } + + require.Equal(t, tt.expectedOffset, int(mockClient.LastSearchRequest.Offset), fmt.Sprintf("mismatch offset in test %d", i)) + require.Equal(t, tt.expectedPage, int(mockClient.LastSearchRequest.Page), fmt.Sprintf("mismatch page in test %d", i)) + } + }) +} + +type MockClient struct { + resourcepb.ResourceIndexClient + resource.ResourceIndex + + // Capture the last SearchRequest for assertions + LastSearchRequest *resourcepb.ResourceSearchRequest + + MockResponses []*resourcepb.ResourceSearchResponse + MockError error + MockCalls []*resourcepb.ResourceSearchRequest + CallCount int +} + +func (m *MockClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + if m.MockError != nil { + return nil, m.MockError + } + + m.LastSearchRequest = in + m.MockCalls = append(m.MockCalls, in) + + var response *resourcepb.ResourceSearchResponse + if m.CallCount < len(m.MockResponses) { + response = m.MockResponses[m.CallCount] + } + + m.CallCount = m.CallCount + 1 + + return response, nil +} +func (m *MockClient) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest, opts ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) { + return nil, nil +} +func (m *MockClient) CountManagedObjects(ctx context.Context, in *resourcepb.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.CountManagedObjectsResponse, error) { + return nil, nil +} +func (m *MockClient) Watch(ctx context.Context, in *resourcepb.WatchRequest, opts ...grpc.CallOption) (resourcepb.ResourceStore_WatchClient, error) { + return nil, nil +} +func (m *MockClient) Delete(ctx context.Context, in *resourcepb.DeleteRequest, opts ...grpc.CallOption) (*resourcepb.DeleteResponse, error) { + return nil, nil +} +func (m *MockClient) Create(ctx context.Context, in *resourcepb.CreateRequest, opts ...grpc.CallOption) (*resourcepb.CreateResponse, error) { + return nil, nil +} +func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, opts ...grpc.CallOption) (*resourcepb.UpdateResponse, error) { + return nil, nil +} +func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) { + return nil, nil +} +func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) { + return nil, nil +} +func (m *MockClient) PutBlob(ctx context.Context, in *resourcepb.PutBlobRequest, opts ...grpc.CallOption) (*resourcepb.PutBlobResponse, error) { + return nil, nil +} +func (m *MockClient) List(ctx context.Context, in *resourcepb.ListRequest, opts ...grpc.CallOption) (*resourcepb.ListResponse, error) { + return nil, nil +} +func (m *MockClient) ListManagedObjects(ctx context.Context, in *resourcepb.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.ListManagedObjectsResponse, error) { + return nil, nil +} +func (m *MockClient) IsHealthy(ctx context.Context, in *resourcepb.HealthCheckRequest, opts ...grpc.CallOption) (*resourcepb.HealthCheckResponse, error) { + return nil, nil +} +func (m *MockClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) { + return nil, nil +} +func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error { + return nil +} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index d1a2ecee64f..d3cf6da6b5a 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -873,7 +873,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService) if err != nil { return nil, err } @@ -1526,7 +1526,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService) if err != nil { return nil, err } diff --git a/pkg/services/team/search/search.go b/pkg/services/team/search/search.go new file mode 100644 index 00000000000..51c6113add3 --- /dev/null +++ b/pkg/services/team/search/search.go @@ -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 +} diff --git a/pkg/services/team/search/search_test.go b/pkg/services/team/search/search_test.go new file mode 100644 index 00000000000..2dc5c6ef588 --- /dev/null +++ b/pkg/services/team/search/search_test.go @@ -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) + }) +} diff --git a/pkg/services/team/teamtest/team.go b/pkg/services/team/teamtest/team.go index 3b88d05d5d3..7b9c87df5fb 100644 --- a/pkg/services/team/teamtest/team.go +++ b/pkg/services/team/teamtest/team.go @@ -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) { diff --git a/pkg/storage/unified/search/builders/document.go b/pkg/storage/unified/search/builders/document.go index 675f95b4b78..9920839f122 100644 --- a/pkg/storage/unified/search/builders/document.go +++ b/pkg/storage/unified/search/builders/document.go @@ -67,5 +67,10 @@ func All(sql db.DB, sprinkles DashboardStats) ([]resource.DocumentBuilderInfo, e return nil, err } - return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings}, nil + teams, err := GetTeamSearchBuilder() + if err != nil { + return nil, err + } + + return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings, teams}, nil } diff --git a/pkg/storage/unified/search/builders/document_test.go b/pkg/storage/unified/search/builders/document_test.go index bee719aeefa..6c8d02d6cb8 100644 --- a/pkg/storage/unified/search/builders/document_test.go +++ b/pkg/storage/unified/search/builders/document_test.go @@ -70,6 +70,18 @@ func TestExternalGroupMappingDocumentBuilder(t *testing.T) { }) } +func TestTeamSearchBuilder(t *testing.T) { + info, err := GetTeamSearchBuilder() + require.NoError(t, err) + doSnapshotTests(t, info.Builder, "team", &resourcepb.ResourceKey{ + Namespace: "default", + Group: "iam.grafana.app", + Resource: "searchTeams", + }, []string{ + "with-email-and-external-uid", + }) +} + func TestDashboardDocumentBuilder(t *testing.T) { key := &resourcepb.ResourceKey{ Namespace: "default", diff --git a/pkg/storage/unified/search/builders/team_search.go b/pkg/storage/unified/search/builders/team_search.go new file mode 100644 index 00000000000..4b09074b3dc --- /dev/null +++ b/pkg/storage/unified/search/builders/team_search.go @@ -0,0 +1,87 @@ +package builders + +import ( + "bytes" + "context" + "encoding/json" + + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +const ( + TEAM_SEARCH_EMAIL = "email" + TEAM_SEARCH_PROVISIONED = "provisioned" + TEAM_SEARCH_EXTERNAL_UID = "externalUID" +) + +var TeamSearchTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefinition{ + TEAM_SEARCH_EMAIL: { + Name: TEAM_SEARCH_EMAIL, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + Description: "Email of the team", + }, + TEAM_SEARCH_PROVISIONED: { + Name: TEAM_SEARCH_PROVISIONED, + Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN, + Description: "Whether the team is provisioned", + }, + TEAM_SEARCH_EXTERNAL_UID: { + Name: TEAM_SEARCH_EXTERNAL_UID, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + Description: "External UID of the team", + }, +} + +func GetTeamSearchBuilder() (resource.DocumentBuilderInfo, error) { + values := make([]*resourcepb.ResourceTableColumnDefinition, 0, len(TeamSearchTableColumnDefinitions)) + for _, v := range TeamSearchTableColumnDefinitions { + values = append(values, v) + } + fields, err := resource.NewSearchableDocumentFields(values) + + return resource.DocumentBuilderInfo{ + GroupResource: schema.GroupResource{ + Group: "iam.grafana.app", + Resource: "searchTeams", + }, + Fields: fields, + Builder: new(teamSearchBuilder), + }, err +} + +var _ resource.DocumentBuilder = new(teamSearchBuilder) + +type teamSearchBuilder struct{} + +func (t *teamSearchBuilder) BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, rv int64, value []byte) (*resource.IndexableDocument, error) { + team := &v0alpha1.Team{} + err := json.NewDecoder(bytes.NewReader(value)).Decode(team) + if err != nil { + return nil, err + } + + obj, err := utils.MetaAccessor(team) + if err != nil { + return nil, err + } + + doc := resource.NewIndexableDocument(key, rv, obj) + + doc.Fields = make(map[string]any) + if team.Spec.Email != "" { + doc.Fields[TEAM_SEARCH_EMAIL] = team.Spec.Email + } + if team.Spec.Provisioned { + doc.Fields[TEAM_SEARCH_PROVISIONED] = team.Spec.Provisioned + } + if team.Spec.ExternalUID != "" { + doc.Fields[TEAM_SEARCH_EXTERNAL_UID] = team.Spec.ExternalUID + } + + return doc, nil +} diff --git a/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json new file mode 100644 index 00000000000..1c05f3c4dc9 --- /dev/null +++ b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json @@ -0,0 +1,18 @@ +{ + "key": { + "namespace": "default", + "group": "iam.grafana.app", + "resource": "searchTeams", + "name": "with-email-and-external-uid" + }, + "name": "with-email-and-external-uid", + "rv": 1234, + "title": "Team With Email And External UID", + "title_ngram": "Team With Email And External UID", + "title_phrase": "team with email and external uid", + "fields": { + "email": "test@example.com", + "externalUID": "external-uid", + "provisioned": true + } +} \ No newline at end of file diff --git a/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json new file mode 100644 index 00000000000..89bbefdb13f --- /dev/null +++ b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json @@ -0,0 +1,13 @@ +{ + "apiVersion": "iam.grafana.app/v0alpha1", + "kind": "Team", + "metadata": { + "name": "team-with-email-and-external-uid" + }, + "spec": { + "title": "Team With Email And External UID", + "email": "test@example.com", + "provisioned": true, + "externalUID": "external-uid" + } +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index 8449498a061..ea0e4039581 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -912,6 +912,115 @@ } ] }, + "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/searchTeams": { + "get": { + "tags": [ + "Search" + ], + "description": "Team search", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "workspace", + "required": true, + "schema": { + "type": "string" + }, + "example": "default" + }, + { + "name": "query", + "in": "query", + "description": "team name query string", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "limit the number of results", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "description": "start the query at the given offset", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "description": "page number to start from", + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + } + } + } + } + } + } + }, "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": { "get": { "tags": [ @@ -6373,6 +6482,90 @@ } } }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + }, "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": { "type": "object", "required": [ @@ -7726,6 +7919,38 @@ } } }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": { + "type": "object", + "required": [ + "name", + "title", + "email", + "provisioned", + "externalUID" + ], + "properties": { + "email": { + "type": "string", + "default": "" + }, + "externalUID": { + "type": "string", + "default": "" + }, + "name": { + "type": "string", + "default": "" + }, + "provisioned": { + "type": "boolean", + "default": false + }, + "title": { + "type": "string", + "default": "" + } + } + }, "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { "description": "APIResource specifies the name of a resource and whether it is namespaced.", "type": "object",