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:
@@ -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
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+135
-3
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct {
|
||||
dual dualwrite.Service
|
||||
unified resource.ResourceClient
|
||||
userSearchClient resourcepb.ResourceIndexClient
|
||||
teamSearch *TeamSearchHandler
|
||||
|
||||
teamGroupsHandler externalgroupmapping.TeamGroupsHandler
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
+13
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user