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
+28
View File
@@ -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
}
}
}
}
}
@@ -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()
@@ -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{}
}
@@ -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{},
}
}
@@ -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()
@@ -317,6 +317,7 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error {
&ServiceAccountList{},
&Team{},
&TeamList{},
&GetSearchTeams{},
&TeamBinding{},
&TeamBindingList{},
&ExternalGroupMapping{},
@@ -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"`
}
+177
View File
@@ -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"},
},
},
}
}
+135 -3
View File
@@ -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||<namespace>/searchTeams|GET": v0alpha1.GetSearchTeams{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
@@ -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<GetSearchTeamsApiResponse, GetSearchTeamsApiArg>({
query: (queryArg) => ({
url: `/searchTeams`,
params: {
query: queryArg.query,
limit: queryArg.limit,
offset: queryArg.offset,
page: queryArg.page,
},
}),
providesTags: ['Search'],
}),
listServiceAccount: build.query<ListServiceAccountApiResponse, ListServiceAccountApiArg>({
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,
+10
View File
@@ -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",
+1
View File
@@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct {
dual dualwrite.Service
unified resource.ResourceClient
userSearchClient resourcepb.ResourceIndexClient
teamSearch *TeamSearchHandler
teamGroupsHandler externalgroupmapping.TeamGroupsHandler
+12 -1
View File
@@ -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 {
+141
View File
@@ -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),
}
}
@@ -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())
})
}
+192
View File
@@ -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)
}
+286
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+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) {
@@ -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
}
@@ -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",
@@ -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
}
@@ -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
}
}
@@ -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"
}
}
@@ -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",