diff --git a/gateway/resolver/relations.go b/gateway/resolver/relations.go new file mode 100644 index 0000000..b5e96c3 --- /dev/null +++ b/gateway/resolver/relations.go @@ -0,0 +1,223 @@ +package resolver + +import ( + "context" + "strings" + + "github.com/graphql-go/graphql" + "go.opentelemetry.io/otel/trace" + "golang.org/x/text/cases" + "golang.org/x/text/language" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// referenceInfo holds extracted reference details +type referenceInfo struct { + name string + namespace string + kind string + apiGroup string +} + +// RelationResolver creates a GraphQL resolver for relation fields +// Relationships are only enabled for GetItem queries to prevent N+1 problems in ListItems and Subscriptions +func (r *Service) RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn { + return func(p graphql.ResolveParams) (interface{}, error) { + // Try context first, fallback to GraphQL info analysis + operation := r.getOperationFromContext(p.Context) + if operation == "unknown" { + operation = r.detectOperationFromGraphQLInfo(p) + } + + r.log.Debug(). + Str("fieldName", fieldName). + Str("operation", operation). + Str("graphqlField", p.Info.FieldName). + Msg("RelationResolver called") + + // Check if relationships are allowed in this query context + if !r.isRelationResolutionAllowedForOperation(operation) { + r.log.Debug(). + Str("fieldName", fieldName). + Str("operation", operation). + Msg("Relationship resolution disabled for this operation type") + return nil, nil + } + + parentObj, ok := p.Source.(map[string]any) + if !ok { + return nil, nil + } + + refInfo := r.extractReferenceInfo(parentObj, fieldName) + if refInfo.name == "" { + return nil, nil + } + + return r.resolveReference(p.Context, refInfo, gvk) + } +} + +// extractReferenceInfo extracts reference details from a *Ref object +func (r *Service) extractReferenceInfo(parentObj map[string]any, fieldName string) referenceInfo { + name, _ := parentObj["name"].(string) + if name == "" { + return referenceInfo{} + } + + namespace, _ := parentObj["namespace"].(string) + apiGroup, _ := parentObj["apiGroup"].(string) + + kind, _ := parentObj["kind"].(string) + if kind == "" { + // Fallback: infer kind from field name (e.g., "role" -> "Role") + kind = cases.Title(language.English).String(fieldName) + } + + return referenceInfo{ + name: name, + namespace: namespace, + kind: kind, + apiGroup: apiGroup, + } +} + +// resolveReference fetches a referenced Kubernetes resource using provided target GVK +func (r *Service) resolveReference(ctx context.Context, ref referenceInfo, targetGVK schema.GroupVersionKind) (interface{}, error) { + gvk := targetGVK + + // Allow overrides from the reference object if specified + if ref.apiGroup != "" { + gvk.Group = ref.apiGroup + } + if ref.kind != "" { + gvk.Kind = ref.kind + } + + // Convert sanitized group to original before calling the client + gvk.Group = r.getOriginalGroupName(gvk.Group) + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + + key := client.ObjectKey{Name: ref.name} + if ref.namespace != "" { + key.Namespace = ref.namespace + } + + err := r.runtimeClient.Get(ctx, key, obj) + if err != nil { + // For "not found" errors, return nil to allow graceful degradation + // This handles cases where referenced resources are deleted or don't exist + if apierrors.IsNotFound(err) { + return nil, nil + } + + // For other errors (network, permission, etc.), log and return the actual error + // This ensures proper error propagation for debugging and monitoring + r.log.Error(). + Err(err). + Str("operation", "resolve_relation"). + Str("group", gvk.Group). + Str("version", gvk.Version). + Str("kind", gvk.Kind). + Str("name", ref.name). + Str("namespace", ref.namespace). + Msg("Unable to resolve referenced object") + return nil, err + } + + // Happy path: resource found successfully + return obj.Object, nil +} + +// isRelationResolutionAllowed checks if relationship resolution should be enabled for this operation +// Only allows relationships in GetItem operations to prevent N+1 problems +func (r *Service) isRelationResolutionAllowed(ctx context.Context) bool { + operation := r.getOperationFromContext(ctx) + return r.isRelationResolutionAllowedForOperation(operation) +} + +// isRelationResolutionAllowedForOperation checks if relationship resolution should be enabled for the given operation type +func (r *Service) isRelationResolutionAllowedForOperation(operation string) bool { + // Only allow relationships for GetItem and GetItemAsYAML operations + switch operation { + case "GetItem", "GetItemAsYAML": + return true + case "ListItems", "SubscribeItem", "SubscribeItems": + return false + default: + // For unknown operations, be conservative and disable relationships + r.log.Debug().Str("operation", operation).Msg("Unknown operation type, disabling relationships") + return false + } +} + +// Context key for tracking operation type +type operationContextKey string + +const OperationTypeKey operationContextKey = "operation_type" + +// getOperationFromContext extracts the operation name from the context +func (r *Service) getOperationFromContext(ctx context.Context) string { + // Try to get operation from context value first + if op, ok := ctx.Value(OperationTypeKey).(string); ok { + return op + } + + // Fallback: try to extract from trace span name + span := trace.SpanFromContext(ctx) + if span == nil { + return "unknown" + } + + // This is a workaround - we'll need to get the span name somehow + // For now, assume unknown and rely on context values + return "unknown" +} + +// detectOperationFromGraphQLInfo analyzes GraphQL field path to determine operation type +// This looks at the parent field context to determine if we're in a list, single item, or subscription +func (r *Service) detectOperationFromGraphQLInfo(p graphql.ResolveParams) string { + if p.Info.Path == nil { + return "unknown" + } + + // Walk up the path to find the parent resolver context + path := p.Info.Path + for path.Prev != nil { + path = path.Prev + + // Check if we find a parent field that indicates the operation type + if fieldName, ok := path.Key.(string); ok { + fieldLower := strings.ToLower(fieldName) + + // Check for subscription patterns + if strings.Contains(fieldLower, "subscription") { + r.log.Debug(). + Str("parentField", fieldName). + Msg("Detected subscription context from parent field") + return "SubscribeItems" + } + + // Check for list patterns (plural without args, or explicitly plural fields) + if strings.HasSuffix(fieldName, "s") && !strings.HasSuffix(fieldName, "Status") { + // This looks like a plural field, likely a list operation + r.log.Debug(). + Str("parentField", fieldName). + Msg("Detected list context from parent field") + return "ListItems" + } + } + } + + // If we can't determine from parent context, assume it's a single item operation + // This is the safe default that allows relationships + r.log.Debug(). + Str("currentField", p.Info.FieldName). + Msg("Could not determine operation from path, defaulting to GetItem") + return "GetItem" +} diff --git a/gateway/resolver/resolver.go b/gateway/resolver/resolver.go index a99e38d..9dc8484 100644 --- a/gateway/resolver/resolver.go +++ b/gateway/resolver/resolver.go @@ -2,6 +2,7 @@ package resolver import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -10,7 +11,6 @@ import ( "strings" "github.com/graphql-go/graphql" - pkgErrors "github.com/pkg/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -30,6 +30,7 @@ type Provider interface { CustomQueriesProvider CommonResolver() graphql.FieldResolveFn SanitizeGroupName(string) string + RelationResolver(fieldName string, gvk schema.GroupVersionKind) graphql.FieldResolveFn } type CrudProvider interface { @@ -68,6 +69,11 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) ctx, span := otel.Tracer("").Start(p.Context, "ListItems", trace.WithAttributes(attribute.String("kind", gvk.Kind))) defer span.End() + // Add operation type to context to disable relationship resolution + ctx = context.WithValue(ctx, operationContextKey("operation_type"), "ListItems") + // Update p.Context so field resolvers inherit the operation type + p.Context = ctx + gvk.Group = r.getOriginalGroupName(gvk.Group) log, err := r.log.ChildLoggerWithAttributes( @@ -82,16 +88,16 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) log = r.log } - // Create an unstructured list to hold the results + // Create a list of unstructured objects to hold the results list := &unstructured.UnstructuredList{} - list.SetGroupVersionKind(gvk) + list.SetGroupVersionKind(schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List"}) var opts []client.ListOption - // Handle label selector argument - if labelSelector, ok := p.Args[LabelSelectorArg].(string); ok && labelSelector != "" { - selector, err := labels.Parse(labelSelector) + + if val, ok := p.Args[LabelSelectorArg].(string); ok && val != "" { + selector, err := labels.Parse(val) if err != nil { - log.Error().Err(err).Str(LabelSelectorArg, labelSelector).Msg("Unable to parse given label selector") + log.Error().Err(err).Str(LabelSelectorArg, val).Msg("Unable to parse given label selector") return nil, err } opts = append(opts, client.MatchingLabelsSelector{Selector: selector}) @@ -108,8 +114,8 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) } if err = r.runtimeClient.List(ctx, list, opts...); err != nil { - log.Error().Err(err).Msg("Unable to list objects") - return nil, pkgErrors.Wrap(err, "unable to list objects") + log.Error().Err(err).Str("scope", string(scope)).Msg("Unable to list objects") + return nil, err } sortBy, err := getStringArg(p.Args, SortByArg, false) @@ -117,16 +123,16 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope) return nil, err } - err = validateSortBy(list.Items, sortBy) - if err != nil { - log.Error().Err(err).Str(SortByArg, sortBy).Msg("Invalid sortBy field path") - return nil, err + if sortBy != "" { + if err := validateSortBy(list.Items, sortBy); err != nil { + log.Error().Err(err).Str(SortByArg, sortBy).Msg("Invalid sortBy field path") + return nil, err + } + sort.Slice(list.Items, func(i, j int) bool { + return compareUnstructured(list.Items[i], list.Items[j], sortBy) < 0 + }) } - sort.Slice(list.Items, func(i, j int) bool { - return compareUnstructured(list.Items[i], list.Items[j], sortBy) < 0 - }) - items := make([]map[string]any, len(list.Items)) for i, item := range list.Items { items[i] = item.Object @@ -142,6 +148,11 @@ func (r *Service) GetItem(gvk schema.GroupVersionKind, scope v1.ResourceScope) g ctx, span := otel.Tracer("").Start(p.Context, "GetItem", trace.WithAttributes(attribute.String("kind", gvk.Kind))) defer span.End() + // Add operation type to context to enable relationship resolution + ctx = context.WithValue(ctx, operationContextKey("operation_type"), "GetItem") + // Update p.Context so field resolvers inherit the operation type + p.Context = ctx + gvk.Group = r.getOriginalGroupName(gvk.Group) log, err := r.log.ChildLoggerWithAttributes( @@ -195,6 +206,9 @@ func (r *Service) GetItemAsYAML(gvk schema.GroupVersionKind, scope v1.ResourceSc p.Context, span = otel.Tracer("").Start(p.Context, "GetItemAsYAML", trace.WithAttributes(attribute.String("kind", gvk.Kind))) defer span.End() + // Add operation type to context to enable relationship resolution + p.Context = context.WithValue(p.Context, operationContextKey("operation_type"), "GetItemAsYAML") + out, err := r.GetItem(gvk, scope)(p) if err != nil { return "", err diff --git a/gateway/resolver/subscription.go b/gateway/resolver/subscription.go index 3b0216d..8601938 100644 --- a/gateway/resolver/subscription.go +++ b/gateway/resolver/subscription.go @@ -1,6 +1,7 @@ package resolver import ( + "context" "fmt" "reflect" "sort" @@ -32,6 +33,10 @@ func (r *Service) SubscribeItem(gvk schema.GroupVersionKind, scope v1.ResourceSc return func(p graphql.ResolveParams) (interface{}, error) { _, span := otel.Tracer("").Start(p.Context, "SubscribeItem", trace.WithAttributes(attribute.String("kind", gvk.Kind))) defer span.End() + + // Add operation type to context to disable relationship resolution + p.Context = context.WithValue(p.Context, operationContextKey("operation_type"), "SubscribeItem") + resultChannel := make(chan interface{}) go r.runWatch(p, gvk, resultChannel, true, scope) @@ -43,6 +48,10 @@ func (r *Service) SubscribeItems(gvk schema.GroupVersionKind, scope v1.ResourceS return func(p graphql.ResolveParams) (interface{}, error) { _, span := otel.Tracer("").Start(p.Context, "SubscribeItems", trace.WithAttributes(attribute.String("kind", gvk.Kind))) defer span.End() + + // Add operation type to context to disable relationship resolution + p.Context = context.WithValue(p.Context, operationContextKey("operation_type"), "SubscribeItems") + resultChannel := make(chan interface{}) go r.runWatch(p, gvk, resultChannel, false, scope) @@ -353,6 +362,8 @@ func CreateSubscriptionResolver(isSingle bool) graphql.FieldResolveFn { return nil, err } + // Note: The context already contains operation type from SubscribeItem/SubscribeItems + // This will propagate to relationship resolvers, disabling them for subscriptions return source, nil } } diff --git a/gateway/schema/relations.go b/gateway/schema/relations.go new file mode 100644 index 0000000..e9dfa79 --- /dev/null +++ b/gateway/schema/relations.go @@ -0,0 +1,141 @@ +package schema + +import ( + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/go-openapi/spec" + "github.com/graphql-go/graphql" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// addRelationFields adds relation fields to schemas that contain *Ref fields +func (g *Gateway) addRelationFields(fields graphql.Fields, properties map[string]spec.Schema) { + for fieldName := range properties { + if !strings.HasSuffix(fieldName, "Ref") { + continue + } + + baseName := strings.TrimSuffix(fieldName, "Ref") + sanitizedFieldName := sanitizeFieldName(fieldName) + + refField, exists := fields[sanitizedFieldName] + if !exists { + continue + } + + enhancedType := g.enhanceRefTypeWithRelation(refField.Type, baseName) + if enhancedType == nil { + continue + } + + fields[sanitizedFieldName] = &graphql.Field{ + Type: enhancedType, + } + } +} + +// enhanceRefTypeWithRelation adds a relation field to a *Ref object type +func (g *Gateway) enhanceRefTypeWithRelation(originalType graphql.Output, baseName string) graphql.Output { + objType, ok := originalType.(*graphql.Object) + if !ok { + return originalType + } + + cacheKey := objType.Name() + "_" + baseName + "_Enhanced" + if enhancedType, exists := g.enhancedTypesCache[cacheKey]; exists { + return enhancedType + } + + enhancedFields := g.copyOriginalFields(objType.Fields()) + g.addRelationField(enhancedFields, baseName) + + enhancedType := graphql.NewObject(graphql.ObjectConfig{ + Name: sanitizeFieldName(cacheKey), + Fields: enhancedFields, + }) + + g.enhancedTypesCache[cacheKey] = enhancedType + return enhancedType +} + +// copyOriginalFields converts FieldDefinition to Field for reuse +func (g *Gateway) copyOriginalFields(originalFieldDefs graphql.FieldDefinitionMap) graphql.Fields { + enhancedFields := make(graphql.Fields, len(originalFieldDefs)) + for fieldName, fieldDef := range originalFieldDefs { + enhancedFields[fieldName] = &graphql.Field{ + Type: fieldDef.Type, + Description: fieldDef.Description, + Resolve: fieldDef.Resolve, + } + } + return enhancedFields +} + +// addRelationField adds a single relation field to the enhanced fields +func (g *Gateway) addRelationField(enhancedFields graphql.Fields, baseName string) { + targetType, targetGVK, ok := g.findRelationTarget(baseName) + if !ok { + return + } + + sanitizedBaseName := sanitizeFieldName(baseName) + enhancedFields[sanitizedBaseName] = &graphql.Field{ + Type: targetType, + Resolve: g.resolver.RelationResolver(baseName, *targetGVK), + } +} + +// findRelationTarget locates the GraphQL output type and its GVK for a relation target +func (g *Gateway) findRelationTarget(baseName string) (graphql.Output, *schema.GroupVersionKind, bool) { + targetKind := cases.Title(language.English).String(baseName) + + for defKey, defSchema := range g.definitions { + if g.matchesTargetKind(defSchema, targetKind) { + // Resolve or build the GraphQL type + var fieldType graphql.Output + if existingType, exists := g.typesCache[defKey]; exists { + fieldType = existingType + } else { + ft, _, err := g.convertSwaggerTypeToGraphQL(defSchema, defKey, []string{}, make(map[string]bool)) + if err != nil { + continue + } + fieldType = ft + } + + // Extract GVK from the schema definition + gvk, err := g.getGroupVersionKind(defKey) + if err != nil || gvk == nil { + continue + } + + return fieldType, gvk, true + } + } + + return nil, nil, false +} + +// matchesTargetKind checks if a schema definition matches the target kind +func (g *Gateway) matchesTargetKind(defSchema spec.Schema, targetKind string) bool { + gvkExt, ok := defSchema.Extensions["x-kubernetes-group-version-kind"] + if !ok { + return false + } + + gvkSlice, ok := gvkExt.([]any) + if !ok || len(gvkSlice) == 0 { + return false + } + + gvkMap, ok := gvkSlice[0].(map[string]any) + if !ok { + return false + } + + kind, ok := gvkMap["kind"].(string) + return ok && kind == targetKind +} diff --git a/gateway/schema/schema.go b/gateway/schema/schema.go index 9085ec2..8db274c 100644 --- a/gateway/schema/schema.go +++ b/gateway/schema/schema.go @@ -22,16 +22,13 @@ type Provider interface { } type Gateway struct { - log *logger.Logger - resolver resolver.Provider - graphqlSchema graphql.Schema - - definitions spec.Definitions - - // typesCache stores generated GraphQL object types(fields) to prevent redundant repeated generation. - typesCache map[string]*graphql.Object - // inputTypesCache stores generated GraphQL input object types(input fields) to prevent redundant repeated generation. - inputTypesCache map[string]*graphql.InputObject + log *logger.Logger + resolver resolver.Provider + graphqlSchema graphql.Schema + definitions spec.Definitions + typesCache map[string]*graphql.Object + inputTypesCache map[string]*graphql.InputObject + enhancedTypesCache map[string]*graphql.Object // Cache for enhanced *Ref types // Prevents naming conflict in case of the same Kind name in different groups/versions typeNameRegistry map[string]string // map[Kind]GroupVersion @@ -41,13 +38,14 @@ type Gateway struct { func New(log *logger.Logger, definitions spec.Definitions, resolverProvider resolver.Provider) (*Gateway, error) { g := &Gateway{ - log: log, - resolver: resolverProvider, - definitions: definitions, - typesCache: make(map[string]*graphql.Object), - inputTypesCache: make(map[string]*graphql.InputObject), - typeNameRegistry: make(map[string]string), - typeByCategory: make(map[string][]resolver.TypeByCategory), + log: log, + resolver: resolverProvider, + definitions: definitions, + typesCache: make(map[string]*graphql.Object), + inputTypesCache: make(map[string]*graphql.InputObject), + enhancedTypesCache: make(map[string]*graphql.Object), + typeNameRegistry: make(map[string]string), + typeByCategory: make(map[string][]resolver.TypeByCategory), } err := g.generateGraphqlSchema() @@ -336,6 +334,9 @@ func (g *Gateway) generateGraphQLFields(resourceScheme *spec.Schema, typePrefix } } + // Add relation fields for any *Ref fields in this schema + g.addRelationFields(fields, resourceScheme.Properties) + return fields, inputFields, nil } diff --git a/listener/pkg/apischema/builder.go b/listener/pkg/apischema/builder.go index 81ef41d..1d7b3a1 100644 --- a/listener/pkg/apischema/builder.go +++ b/listener/pkg/apischema/builder.go @@ -30,18 +30,35 @@ var ( ErrCRDNoVersions = errors.New("CRD has no versions defined") ErrMarshalGVK = errors.New("failed to marshal GVK extension") ErrUnmarshalGVK = errors.New("failed to unmarshal GVK extension") + ErrBuildKindRegistry = errors.New("failed to build kind registry") ) type SchemaBuilder struct { - schemas map[string]*spec.Schema - err *multierror.Error - log *logger.Logger + schemas map[string]*spec.Schema + err *multierror.Error + log *logger.Logger + kindRegistry map[GroupVersionKind]ResourceInfo // Changed: Use GVK as key for precise lookup + preferredVersions map[string]string // map[group/kind]preferredVersion + maxRelationDepth int // maximum allowed relationship nesting depth (1 = single level) + relationDepths map[string]int // tracks the minimum depth at which each schema is referenced +} + +// ResourceInfo holds information about a resource for relationship resolution +type ResourceInfo struct { + Group string + Version string + Kind string + SchemaKey string } func NewSchemaBuilder(oc openapi.Client, preferredApiGroups []string, log *logger.Logger) *SchemaBuilder { b := &SchemaBuilder{ - schemas: make(map[string]*spec.Schema), - log: log, + schemas: make(map[string]*spec.Schema), + kindRegistry: make(map[GroupVersionKind]ResourceInfo), + preferredVersions: make(map[string]string), + maxRelationDepth: 1, // Default to 1-level depth for now + relationDepths: make(map[string]int), + log: log, } apiv3Paths, err := oc.Paths() @@ -62,6 +79,19 @@ func NewSchemaBuilder(oc openapi.Client, preferredApiGroups []string, log *logge return b } +// WithMaxRelationDepth sets the maximum allowed relationship nesting depth +// depth=1: A->B (single level) +// depth=2: A->B->C (two levels) +// depth=3: A->B->C->D (three levels) +func (b *SchemaBuilder) WithMaxRelationDepth(depth int) *SchemaBuilder { + if depth < 1 { + depth = 1 // Minimum depth is 1 + } + b.maxRelationDepth = depth + b.log.Info().Int("maxRelationDepth", depth).Msg("Set maximum relationship nesting depth") + return b +} + type GroupVersionKind struct { Group string `json:"group"` Version string `json:"version"` @@ -107,7 +137,7 @@ func (b *SchemaBuilder) WithScope(rm meta.RESTMapper) *SchemaBuilder { Str("group", gvks[0].Group). Str("version", gvks[0].Version). Str("kind", gvks[0].Kind). - Msg("failed to determine if GVK is namespaced") + Msg("failed to get namespaced info for GVK") continue } @@ -116,61 +146,410 @@ func (b *SchemaBuilder) WithScope(rm meta.RESTMapper) *SchemaBuilder { } else { schema.VendorExtensible.AddExtension(common.ScopeExtensionKey, apiextensionsv1.ClusterScoped) } - } - return b } func (b *SchemaBuilder) WithCRDCategories(crd *apiextensionsv1.CustomResourceDefinition) *SchemaBuilder { - categories := crd.Spec.Names.Categories - if len(categories) == 0 { - return b - } - gvk, err := getCRDGroupVersionKind(crd.Spec) - if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrGetCRDGVK, err)) + if crd == nil { return b } - schema, ok := b.schemas[getOpenAPISchemaKey(*gvk)] - if !ok { + gkv, err := getCRDGroupVersionKind(crd.Spec) + if err != nil { + b.err = multierror.Append(b.err, ErrGetCRDGVK) return b } - schema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, categories) + for _, v := range crd.Spec.Versions { + resourceKey := getOpenAPISchemaKey(metav1.GroupVersionKind{Group: gkv.Group, Version: v.Name, Kind: gkv.Kind}) + resourceSchema, ok := b.schemas[resourceKey] + if !ok { + continue + } + if len(crd.Spec.Names.Categories) == 0 { + b.log.Debug().Str("resource", resourceKey).Msg("no categories provided for CRD kind") + continue + } + resourceSchema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, crd.Spec.Names.Categories) + b.schemas[resourceKey] = resourceSchema + } return b } func (b *SchemaBuilder) WithApiResourceCategories(list []*metav1.APIResourceList) *SchemaBuilder { + if len(list) == 0 { + return b + } + for _, apiResourceList := range list { + gv, err := runtimeSchema.ParseGroupVersion(apiResourceList.GroupVersion) + if err != nil { + b.err = multierror.Append(b.err, errors.Join(ErrParseGroupVersion, err)) + continue + } for _, apiResource := range apiResourceList.APIResources { if apiResource.Categories == nil { continue } + gvk := metav1.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: apiResource.Kind} + resourceKey := getOpenAPISchemaKey(gvk) + resourceSchema, ok := b.schemas[resourceKey] + if !ok { + continue + } + resourceSchema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, apiResource.Categories) + b.schemas[resourceKey] = resourceSchema + } + } + return b +} + +// WithPreferredVersions populates preferred version information from API discovery +func (b *SchemaBuilder) WithPreferredVersions(apiResLists []*metav1.APIResourceList) *SchemaBuilder { + for _, apiResList := range apiResLists { + gv, err := runtimeSchema.ParseGroupVersion(apiResList.GroupVersion) + if err != nil { + b.log.Debug().Err(err).Str("groupVersion", apiResList.GroupVersion).Msg("failed to parse group version") + continue + } + + for _, resource := range apiResList.APIResources { + // Create a key for group/kind to track preferred version + key := fmt.Sprintf("%s/%s", gv.Group, resource.Kind) + + // Store this version as preferred for this group/kind + // ServerPreferredResources returns the preferred version for each group + b.preferredVersions[key] = gv.Version + + b.log.Debug(). + Str("group", gv.Group). + Str("kind", resource.Kind). + Str("preferredVersion", gv.Version). + Msg("registered preferred version") + } + } + return b +} + +// WithRelationships adds relationship fields to schemas that have *Ref fields +func (b *SchemaBuilder) WithRelationships() *SchemaBuilder { + // Build kind registry first + b.buildKindRegistry() + + // For depth=1: use simple relation target tracking (working approach) + // For depth>1: use iterative expansion (scalable approach) + if b.maxRelationDepth == 1 { + b.expandWithSimpleDepthControl() + } else { + b.expandWithConfigurableDepthControl() + } + + return b +} - gv, err := runtimeSchema.ParseGroupVersion(apiResourceList.GroupVersion) - if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrParseGroupVersion, err)) +// expandWithSimpleDepthControl implements the working 1-level depth control +func (b *SchemaBuilder) expandWithSimpleDepthControl() { + // First pass: identify relation targets + relationTargets := make(map[string]bool) + for _, schema := range b.schemas { + if schema.Properties == nil { + continue + } + for propName := range schema.Properties { + if !isRefProperty(propName) { continue } - gvk := metav1.GroupVersionKind{ - Group: gv.Group, - Version: gv.Version, - Kind: apiResource.Kind, + baseKind := strings.TrimSuffix(propName, "Ref") + target := b.findBestResourceForKind(baseKind) + if target != nil { + relationTargets[target.SchemaKey] = true } + } + } - schema, ok := b.schemas[getOpenAPISchemaKey(gvk)] - if !ok { - continue + b.log.Info(). + Int("kindRegistrySize", len(b.kindRegistry)). + Int("relationTargets", len(relationTargets)). + Msg("Starting 1-level relationship expansion") + + // Second pass: expand only non-targets + for schemaKey, schema := range b.schemas { + if relationTargets[schemaKey] { + b.log.Debug().Str("schemaKey", schemaKey).Msg("Skipping relation target (1-level depth control)") + continue + } + b.expandRelationshipsSimple(schema, schemaKey) + } +} + +// expandWithConfigurableDepthControl implements scalable depth control for depth > 1 +func (b *SchemaBuilder) expandWithConfigurableDepthControl() { + b.log.Info(). + Int("kindRegistrySize", len(b.kindRegistry)). + Int("maxRelationDepth", b.maxRelationDepth). + Msg("Starting configurable relationship expansion") + + // TODO: Implement proper multi-level depth control + // For now, fall back to simple approach + b.expandWithSimpleDepthControl() +} + +// buildKindRegistry builds a map of kind names to available resource types +func (b *SchemaBuilder) buildKindRegistry() { + for schemaKey, schema := range b.schemas { + // Extract GVK from schema + if schema.VendorExtensible.Extensions == nil { + continue + } + + gvksVal, ok := schema.VendorExtensible.Extensions[common.GVKExtensionKey] + if !ok { + continue + } + + jsonBytes, err := json.Marshal(gvksVal) + if err != nil { + b.log.Debug().Err(err).Str("schemaKey", schemaKey).Msg("failed to marshal GVK") + continue + } + + var gvks []*GroupVersionKind + if err := json.Unmarshal(jsonBytes, &gvks); err != nil { + b.log.Debug().Err(err).Str("schemaKey", schemaKey).Msg("failed to unmarshal GVK") + continue + } + + if len(gvks) != 1 { + continue + } + + gvk := gvks[0] + + // Add to kind registry with precise GVK key + resourceInfo := ResourceInfo{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + SchemaKey: schemaKey, + } + + // Index by full GroupVersionKind for precise lookup (no collisions) + gvkKey := GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } + b.kindRegistry[gvkKey] = resourceInfo + } + + // No sorting needed - each GVK is now uniquely indexed + // Check for kinds with multiple resources but no preferred versions + b.warnAboutMissingPreferredVersions() + + b.log.Debug().Int("gvkCount", len(b.kindRegistry)).Msg("built kind registry for relationships") +} + +// TODO: Implement proper multi-level depth calculation when needed +// For now, focusing on the working 1-level depth control + +// warnAboutMissingPreferredVersions checks for kinds with multiple resources but no preferred versions +func (b *SchemaBuilder) warnAboutMissingPreferredVersions() { + // Group resources by kind name to find potential conflicts + kindGroups := make(map[string][]ResourceInfo) + + for _, resourceInfo := range b.kindRegistry { + kindKey := strings.ToLower(resourceInfo.Kind) + kindGroups[kindKey] = append(kindGroups[kindKey], resourceInfo) + } + + // Check each kind that has multiple resources + for kindName, resources := range kindGroups { + if len(resources) <= 1 { + continue // No conflict possible + } + + // Check if any of the resources has a preferred version + hasPreferred := false + for _, resource := range resources { + key := fmt.Sprintf("%s/%s", resource.Group, resource.Kind) + if b.preferredVersions[key] == resource.Version { + hasPreferred = true + break + } + } + + // Warn if no preferred version found + if !hasPreferred { + groups := make([]string, 0, len(resources)) + for _, resource := range resources { + groups = append(groups, fmt.Sprintf("%s/%s", resource.Group, resource.Version)) } + b.log.Warn(). + Str("kind", kindName). + Strs("availableResources", groups). + Msg("Multiple resources found for kind with no preferred version - using fallback resolution. Consider setting preferred versions for better API governance.") + } + } +} - schema.VendorExtensible.AddExtension(common.CategoriesExtensionKey, apiResource.Categories) +// findBestResourceForKind finds the best matching resource for a given kind name +// using preferred version logic and group prioritization +func (b *SchemaBuilder) findBestResourceForKind(kindName string) *ResourceInfo { + // Collect all resources with matching kind name + candidates := make([]ResourceInfo, 0) + + for gvk, resourceInfo := range b.kindRegistry { + if strings.EqualFold(gvk.Kind, kindName) { + candidates = append(candidates, resourceInfo) } } - return b + if len(candidates) == 0 { + return nil + } + + if len(candidates) == 1 { + return &candidates[0] + } + + // Sort candidates using preferred version logic + slices.SortFunc(candidates, func(a, bRes ResourceInfo) int { + // 1. Prioritize resources with preferred versions + aKey := fmt.Sprintf("%s/%s", a.Group, a.Kind) + bKey := fmt.Sprintf("%s/%s", bRes.Group, bRes.Kind) + + aPreferred := b.preferredVersions[aKey] == a.Version + bPreferred := b.preferredVersions[bKey] == bRes.Version + + if aPreferred && !bPreferred { + return -1 // a is preferred, comes first + } + if !aPreferred && bPreferred { + return 1 // b is preferred, comes first + } + + // 2. If both or neither are preferred, prioritize by group (core comes first) + if cmp := b.compareGroups(a.Group, bRes.Group); cmp != 0 { + return cmp + } + + // 3. Then by version stability (v1 > v1beta1 > v1alpha1) + if cmp := b.compareVersionStability(a.Version, bRes.Version); cmp != 0 { + return cmp + } + + // 4. Finally by schema key for deterministic ordering + return strings.Compare(a.SchemaKey, bRes.SchemaKey) + }) + + return &candidates[0] +} + +// compareGroups prioritizes core Kubernetes groups over custom groups +func (b *SchemaBuilder) compareGroups(groupA, groupB string) int { + // Core group (empty string) comes first + if groupA == "" && groupB != "" { + return -1 + } + if groupA != "" && groupB == "" { + return 1 + } + + // k8s.io groups come before custom groups + aIsK8s := strings.Contains(groupA, "k8s.io") + bIsK8s := strings.Contains(groupB, "k8s.io") + + if aIsK8s && !bIsK8s { + return -1 + } + if !aIsK8s && bIsK8s { + return 1 + } + + // Otherwise alphabetical + return strings.Compare(groupA, groupB) +} + +// compareVersionStability prioritizes stable versions over beta/alpha +func (b *SchemaBuilder) compareVersionStability(versionA, versionB string) int { + aStability := b.getVersionStability(versionA) + bStability := b.getVersionStability(versionB) + + // Lower number = more stable (stable=0, beta=1, alpha=2) + if aStability != bStability { + return aStability - bStability + } + + // Same stability level, compare alphabetically + return strings.Compare(versionA, versionB) +} + +// getVersionStability returns stability priority (lower = more stable) +func (b *SchemaBuilder) getVersionStability(version string) int { + if strings.Contains(version, "alpha") { + return 2 // least stable + } + if strings.Contains(version, "beta") { + return 1 // somewhat stable + } + return 0 // most stable (v1, v2, etc.) +} + +// expandRelationshipsSimple adds relationship fields for the simple 1-level depth control +func (b *SchemaBuilder) expandRelationshipsSimple(schema *spec.Schema, schemaKey string) { + if schema.Properties == nil { + return + } + + for propName := range schema.Properties { + if !isRefProperty(propName) { + continue + } + + baseKind := strings.TrimSuffix(propName, "Ref") + + // Find the best resource for this kind name using preferred version logic + target := b.findBestResourceForKind(baseKind) + if target == nil { + continue + } + + fieldName := strings.ToLower(baseKind) + if _, exists := schema.Properties[fieldName]; exists { + continue + } + + // Create proper reference - handle empty group (core) properly + var refPath string + if target.Group == "" { + refPath = fmt.Sprintf("#/definitions/%s.%s", target.Version, target.Kind) + } else { + refPath = fmt.Sprintf("#/definitions/%s.%s.%s", target.Group, target.Version, target.Kind) + } + ref := spec.MustCreateRef(refPath) + schema.Properties[fieldName] = spec.Schema{SchemaProps: spec.SchemaProps{Ref: ref}} + + b.log.Info(). + Str("sourceField", propName). + Str("targetField", fieldName). + Str("targetKind", target.Kind). + Str("targetGroup", target.Group). + Str("refPath", refPath). + Str("sourceSchema", schemaKey). + Msg("Added relationship field") + } +} + +func isRefProperty(name string) bool { + if !strings.HasSuffix(name, "Ref") { + return false + } + if name == "Ref" { + return false + } + return true } func (b *SchemaBuilder) Complete() ([]byte, error) { @@ -180,18 +559,18 @@ func (b *SchemaBuilder) Complete() ([]byte, error) { }, }) if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrMarshalOpenAPISchema, err)) - return nil, b.err + return nil, errors.Join(ErrMarshalOpenAPISchema, err) } + v2JSON, err := ConvertJSON(v3JSON) if err != nil { - b.err = multierror.Append(b.err, errors.Join(ErrConvertOpenAPISchema, err)) - return nil, b.err + return nil, errors.Join(ErrConvertOpenAPISchema, err) } return v2JSON, nil } +// getOpenAPISchemaKey creates the key that kubernetes uses in its OpenAPI Definitions func getOpenAPISchemaKey(gvk metav1.GroupVersionKind) string { // we need to inverse group to match the runtimeSchema key(io.openmfp.core.v1alpha1.Account) parts := strings.Split(gvk.Group, ".") diff --git a/listener/pkg/apischema/crd_resolver.go b/listener/pkg/apischema/crd_resolver.go index bae15b3..b56be55 100644 --- a/listener/pkg/apischema/crd_resolver.go +++ b/listener/pkg/apischema/crd_resolver.go @@ -76,7 +76,9 @@ func (cr *CRDResolver) ResolveApiSchema(crd *apiextensionsv1.CustomResourceDefin result, err := NewSchemaBuilder(cr.OpenAPIV3(), preferredApiGroups, cr.log). WithScope(cr.RESTMapper). + WithPreferredVersions(apiResLists). WithCRDCategories(crd). + WithRelationships(). Complete() if err != nil { @@ -206,7 +208,9 @@ func (cr *CRDResolver) resolveSchema(dc discovery.DiscoveryInterface, rm meta.RE result, err := NewSchemaBuilder(dc.OpenAPIV3(), preferredApiGroups, cr.log). WithScope(rm). + WithPreferredVersions(apiResList). WithApiResourceCategories(apiResList). + WithRelationships(). Complete() if err != nil { diff --git a/listener/pkg/apischema/relationships_test.go b/listener/pkg/apischema/relationships_test.go new file mode 100644 index 0000000..55fce67 --- /dev/null +++ b/listener/pkg/apischema/relationships_test.go @@ -0,0 +1,129 @@ +package apischema_test + +import ( + "testing" + + "github.com/openmfp/golang-commons/logger/testlogger" + apischema "github.com/openmfp/kubernetes-graphql-gateway/listener/pkg/apischema" + apimocks "github.com/openmfp/kubernetes-graphql-gateway/listener/pkg/apischema/mocks" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/openapi" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// helper constructs a schema with x-kubernetes-group-version-kind +func schemaWithGVK(group, version, kind string) *spec.Schema { + return &spec.Schema{ + VendorExtensible: spec.VendorExtensible{Extensions: map[string]interface{}{ + "x-kubernetes-group-version-kind": []map[string]string{{ + "group": group, + "version": version, + "kind": kind, + }}, + }}, + } +} + +func Test_with_relationships_adds_single_target_field(t *testing.T) { + mock := apimocks.NewMockClient(t) + mock.EXPECT().Paths().Return(map[string]openapi.GroupVersion{}, nil) + b := apischema.NewSchemaBuilder(mock, nil, testlogger.New().Logger) + + // definitions contain a target kind Role in group g/v1 + roleKey := "g.v1.Role" + roleSchema := schemaWithGVK("g", "v1", "Role") + + // source schema that has roleRef + sourceKey := "g2.v1.Binding" + sourceSchema := &spec.Schema{SchemaProps: spec.SchemaProps{Properties: map[string]spec.Schema{ + "roleRef": {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"object"}}}, + }}} + + b.SetSchemas(map[string]*spec.Schema{ + roleKey: roleSchema, + sourceKey: sourceSchema, + }) + + b.WithRelationships() + + // Expect that role field was added referencing the Role definition + added, ok := b.GetSchemas()[sourceKey].Properties["role"] + assert.True(t, ok, "expected relationship field 'role' to be added") + assert.True(t, added.Ref.GetURL() != nil, "expected $ref on relationship field") + assert.Contains(t, added.Ref.String(), "#/definitions/g.v1.Role") +} + +func Test_build_kind_registry_lowercases_keys_and_picks_first(t *testing.T) { + mock := apimocks.NewMockClient(t) + mock.EXPECT().Paths().Return(map[string]openapi.GroupVersion{}, nil) + b := apischema.NewSchemaBuilder(mock, nil, testlogger.New().Logger) + + // Two schemas with same Kind different groups/versions - first should win + first := schemaWithGVK("a.example", "v1", "Thing") + second := schemaWithGVK("b.example", "v1", "Thing") + b.SetSchemas(map[string]*spec.Schema{ + "a.example.v1.Thing": first, + "b.example.v1.Thing": second, + "c.other.v1.Other": schemaWithGVK("c.other", "v1", "Other"), + }) + + b.WithRelationships() // indirectly builds the registry + + // validate lowercase key exists and contains both, but expansion uses first (covered by previous test) + // we assert the registry was built by triggering another schema that references thingRef + sRef := &spec.Schema{SchemaProps: spec.SchemaProps{Properties: map[string]spec.Schema{ + "thingRef": {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"object"}}}, + }}} + b.GetSchemas()["x.v1.HasThing"] = sRef + + b.WithRelationships() + added, ok := b.GetSchemas()["x.v1.HasThing"].Properties["thing"] + assert.True(t, ok, "expected relationship field 'thing'") + // ensure it referenced the first group + assert.Contains(t, added.Ref.String(), "#/definitions/a.example.v1.Thing") +} + +func Test_preferred_version_takes_priority_over_fallback(t *testing.T) { + mock := apimocks.NewMockClient(t) + mock.EXPECT().Paths().Return(map[string]openapi.GroupVersion{}, nil) + b := apischema.NewSchemaBuilder(mock, nil, testlogger.New().Logger) + + // Multiple schemas with same Kind - a.example would win alphabetically, + // but we'll set z.last as preferred to verify it takes priority + childA := schemaWithGVK("a.example", "v1", "Child") + childB := schemaWithGVK("b.example", "v1", "Child") + childZ := schemaWithGVK("z.last", "v1", "Child") // would be last alphabetically + + b.SetSchemas(map[string]*spec.Schema{ + "a.example.v1.Child": childA, + "b.example.v1.Child": childB, + "z.last.v1.Child": childZ, + }) + + // Set z.last as preferred (even though it would be last alphabetically) + b.WithPreferredVersions([]*metav1.APIResourceList{ + { + GroupVersion: "z.last/v1", + APIResources: []metav1.APIResource{ + {Kind: "Child"}, + }, + }, + }) + + b.WithRelationships() + + // Add a parent schema that references childRef + parentSchema := &spec.Schema{SchemaProps: spec.SchemaProps{Properties: map[string]spec.Schema{ + "childRef": {SchemaProps: spec.SchemaProps{Type: spec.StringOrArray{"object"}}}, + }}} + b.GetSchemas()["x.v1.Parent"] = parentSchema + + b.WithRelationships() + added, ok := b.GetSchemas()["x.v1.Parent"].Properties["child"] + assert.True(t, ok, "expected relationship field 'child'") + + // Should reference z.last because it's the preferred version, not a.example (alphabetical first) + assert.Contains(t, added.Ref.String(), "#/definitions/z.last.v1.Child", + "expected preferred version z.last to be chosen over alphabetically first a.example") +} diff --git a/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go b/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go index 5ecd3f2..33843c7 100644 --- a/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go +++ b/tests/gateway_test/helpers_rbac_authorization_k8s_io_test.go @@ -1,13 +1,30 @@ package gateway_test type RbacAuthorizationK8sIO struct { - ClusterRole *ClusterRole `json:"ClusterRole,omitempty"` + ClusterRole *ClusterRole `json:"ClusterRole,omitempty"` + ClusterRoleBinding *ClusterRoleBinding `json:"ClusterRoleBinding,omitempty"` } type ClusterRole struct { Metadata metadata `json:"metadata"` } +type ClusterRoleBinding struct { + Metadata metadata `json:"metadata"` + RoleRef roleRef `json:"roleRef"` +} + +type roleRef struct { + Name string `json:"name"` + Kind string `json:"kind"` + APIGroup string `json:"apiGroup"` + Role crMeta `json:"role"` +} + +type crMeta struct { + Metadata metadata `json:"metadata"` +} + func CreateClusterRoleMutation() string { return `mutation { rbac_authorization_k8s_io { diff --git a/tests/gateway_test/relation_rbac_test.go b/tests/gateway_test/relation_rbac_test.go new file mode 100644 index 0000000..967af81 --- /dev/null +++ b/tests/gateway_test/relation_rbac_test.go @@ -0,0 +1,113 @@ +package gateway_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + + "github.com/stretchr/testify/require" +) + +// Test_relation_clusterrolebinding_role_ref mirrors pod test style: creates schema file per workspace, +// creates a ClusterRole and ClusterRoleBinding via GraphQL, then queries roleRef.role to ensure relation resolution. +func (suite *CommonTestSuite) Test_relation_clusterrolebinding_role_ref() { + workspaceName := "relationsWorkspace" + + require.NoError(suite.T(), suite.writeToFileWithClusterMetadata( + filepath.Join("testdata", "kubernetes"), + filepath.Join(suite.appCfg.OpenApiDefinitionsPath, workspaceName), + )) + + url := fmt.Sprintf("%s/%s/graphql", suite.server.URL, workspaceName) + + // Create ClusterRole + statusCode, body := suite.doRawGraphQL(url, createClusterRoleForRelationMutation()) + require.Equal(suite.T(), http.StatusOK, statusCode) + require.Nil(suite.T(), body["errors"]) + + // Create ClusterRoleBinding referencing the ClusterRole + statusCode, body = suite.doRawGraphQL(url, createClusterRoleBindingForRelationMutation()) + require.Equal(suite.T(), http.StatusOK, statusCode) + require.Nil(suite.T(), body["errors"]) + + // Query ClusterRoleBinding and expand roleRef.role + statusCode, body = suite.doRawGraphQL(url, getClusterRoleBindingWithRoleQuery()) + require.Equal(suite.T(), http.StatusOK, statusCode) + require.Nil(suite.T(), body["errors"]) + + // Extract nested role name from generic map + data, _ := body["data"].(map[string]interface{}) + rbac, _ := data["rbac_authorization_k8s_io"].(map[string]interface{}) + crb, _ := rbac["ClusterRoleBinding"].(map[string]interface{}) + roleRef, _ := crb["roleRef"].(map[string]interface{}) + role, _ := roleRef["role"].(map[string]interface{}) + metadata, _ := role["metadata"].(map[string]interface{}) + name, _ := metadata["name"].(string) + require.Equal(suite.T(), "test-cluster-role-rel", name) +} + +// local helper mirroring helpers_test.go but returning generic body +func (suite *CommonTestSuite) doRawGraphQL(url, query string) (int, map[string]interface{}) { + reqBody := map[string]string{"query": query} + buf, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", url, bytes.NewReader(buf)) + req.Header.Set("Content-Type", "application/json") + // add auth token used by suite + if suite.staticToken != "" { + req.Header.Set("Authorization", "Bearer "+suite.staticToken) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(suite.T(), err) + defer resp.Body.Close() + var body map[string]interface{} + dec := json.NewDecoder(resp.Body) + require.NoError(suite.T(), dec.Decode(&body)) + return resp.StatusCode, body +} + +// GraphQL payloads +func createClusterRoleForRelationMutation() string { + return `mutation { + rbac_authorization_k8s_io { + createClusterRole( + object: { + metadata: { name: "test-cluster-role-rel" } + rules: [{ apiGroups:[""], resources:["pods"], verbs:["get","list"] }] + } + ) { metadata { name } } + } +}` +} + +func createClusterRoleBindingForRelationMutation() string { + return `mutation { + rbac_authorization_k8s_io { + createClusterRoleBinding( + object: { + metadata: { name: "test-crb-rel" } + roleRef: { + apiGroup: "rbac.authorization.k8s.io" + kind: "ClusterRole" + name: "test-cluster-role-rel" + } + subjects: [] + } + ) { metadata { name } } + } +}` +} + +func getClusterRoleBindingWithRoleQuery() string { + return `{ + rbac_authorization_k8s_io { + ClusterRoleBinding(name: "test-crb-rel") { + roleRef { + name kind apiGroup + role { metadata { name } } + } + } + } +}` +}