diff --git a/api/v1alpha1/const.go b/api/v1alpha1/const.go index af7fafbb..fd85a7ec 100644 --- a/api/v1alpha1/const.go +++ b/api/v1alpha1/const.go @@ -3,4 +3,7 @@ package v1alpha1 const ( SuccessfullyReconciled = "SuccessfullyReconciled" ReconcileFailed = "ReconcileFailed" + + // MaxLDAPQueryDepth is the maximum nesting depth allowed for ldap_query filters. + MaxLDAPQueryDepth = 4 ) diff --git a/api/v1alpha1/group_types.go b/api/v1alpha1/group_types.go index fef92212..2b2d5e21 100644 --- a/api/v1alpha1/group_types.go +++ b/api/v1alpha1/group_types.go @@ -37,18 +37,27 @@ type Backend struct { } type LDAPFilter struct { + // +optional // +kubebuilder:validation:Enum=givenName;displayName;rhatJobTitle;title;employeeType;manager;rhatCostCenter;rhatCostCenterDesc;rhatGeo;co;st;rhatLocation;rhatOfficeLocation;rhatOfficeFloor;roomNumber - Key string `json:"key"` + Key string `json:"key,omitempty"` + // +optional // +kubebuilder:validation:Enum=equals;contains;not - Criteria string `json:"criteria"` - Value string `json:"value"` + Criteria string `json:"criteria,omitempty"` + // +optional + Value string `json:"value,omitempty"` + // +optional + // +kubebuilder:validation:Schemaless + // +kubebuilder:pruning:PreserveUnknownFields + LDAPQuery *LDAPQuery `json:"ldap_query,omitempty"` } type LDAPQuery struct { // +kubebuilder:validation:Enum=and;or - Operator string `json:"operator"` - Filters []LDAPFilter `json:"filters"` - Options *LDAPOptions `json:"options,omitempty"` + Operator string `json:"operator"` + // +kubebuilder:validation:MinItems=1 + Filters []LDAPFilter `json:"filters"` + // +optional + Options *LDAPOptions `json:"options,omitempty"` } type LDAPOptions struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cc7de36e..a2396663 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -197,6 +197,11 @@ func (in *GroupStatus) DeepCopy() *GroupStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LDAPFilter) DeepCopyInto(out *LDAPFilter) { *out = *in + if in.LDAPQuery != nil { + in, out := &in.LDAPQuery, &out.LDAPQuery + *out = new(LDAPQuery) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LDAPFilter. @@ -230,7 +235,9 @@ func (in *LDAPQuery) DeepCopyInto(out *LDAPQuery) { if in.Filters != nil { in, out := &in.Filters, &out.Filters *out = make([]LDAPFilter, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.Options != nil { in, out := &in.Options, &out.Options diff --git a/config/crd/bases/operator.dataverse.redhat.com_groups.yaml b/config/crd/bases/operator.dataverse.redhat.com_groups.yaml index 84413419..57c5162f 100644 --- a/config/crd/bases/operator.dataverse.redhat.com_groups.yaml +++ b/config/crd/bases/operator.dataverse.redhat.com_groups.yaml @@ -119,13 +119,12 @@ spec: - rhatOfficeFloor - roomNumber type: string + ldap_query: + x-kubernetes-preserve-unknown-fields: true value: type: string - required: - - criteria - - key - - value type: object + minItems: 1 type: array operator: enum: diff --git a/config/samples/v1alpha1_group_querybased_nested.yaml b/config/samples/v1alpha1_group_querybased_nested.yaml new file mode 100644 index 00000000..9d0c7cee --- /dev/null +++ b/config/samples/v1alpha1_group_querybased_nested.yaml @@ -0,0 +1,54 @@ +apiVersion: operator.dataverse.redhat.com/v1alpha1 +kind: Group +metadata: + labels: + app.kubernetes.io/name: usernaut + app.kubernetes.io/managed-by: kustomize + name: test-nested-query + namespace: usernaut +spec: + group_name: dataverse-consumer-test-nested + members: + ldap_query: + options: + include_indirect_reports: true + include_manager: true + operator: and + filters: + - key: employeeType + criteria: not + value: "external employee" + - ldap_query: + operator: or + filters: + - key: title + criteria: contains + value: engineer + - key: title + criteria: contains + value: developer + - ldap_query: + operator: or + filters: + - key: manager + criteria: equals + value: mgrAlpha + - key: manager + criteria: equals + value: mgrBeta + - ldap_query: + operator: and + filters: + - key: manager + criteria: equals + value: mgrGamma + - ldap_query: + operator: or + filters: + - key: rhatGeo + criteria: equals + value: na + - key: rhatGeo + criteria: equals + value: apac + backends: [] diff --git a/internal/controller/group_controller.go b/internal/controller/group_controller.go index d972a619..40d02cdb 100644 --- a/internal/controller/group_controller.go +++ b/internal/controller/group_controller.go @@ -264,13 +264,7 @@ func (r *GroupReconciler) fetchQueryMembers(ctx context.Context, query *usernaut log.WithField("query_members_count", len(queryMembers)).Info("query members fetched successfully") - hasManagerFilter := false - for _, filter := range query.Filters { - if strings.EqualFold(strings.TrimSpace(filter.Key), "manager") { - hasManagerFilter = true - break - } - } + hasManagerFilter := queryHasManagerFilter(query) // Manager filter present but indirect reports disabled: return only direct reports of the manager in the query (no recursion). if hasManagerFilter && !includeIndirectReports { @@ -279,9 +273,6 @@ func (r *GroupReconciler) fetchQueryMembers(ctx context.Context, query *usernaut if hasManagerFilter && includeIndirectReports { log.Info("has manager filter, fetching indirect reports") - nestedQuery := usernautdevv1alpha1.LDAPQuery{ - Operator: query.Operator, - } queue := make([]string, 0, len(queryMembers)) queue = append(queue, queryMembers...) @@ -304,17 +295,10 @@ func (r *GroupReconciler) fetchQueryMembers(ctx context.Context, query *usernaut } visited[member] = struct{}{} - nestedQuery.Filters = make([]usernautdevv1alpha1.LDAPFilter, 0, len(query.Filters)) - for _, filter := range query.Filters { - value := filter.Value - if strings.EqualFold(strings.TrimSpace(filter.Key), "manager") { - value = member - } - nestedQuery.Filters = append(nestedQuery.Filters, usernautdevv1alpha1.LDAPFilter{ - Key: filter.Key, - Criteria: filter.Criteria, - Value: value, - }) + nestedQuery := usernautdevv1alpha1.LDAPQuery{ + Operator: query.Operator, + Filters: replaceManagerInFilters(query.Filters, member), + Options: query.Options, } nestedQueryMembers, err := r.fetchQueryMembers(ctx, &nestedQuery, includeIndirectReports, visited) if err != nil { @@ -332,18 +316,93 @@ func (r *GroupReconciler) fetchQueryMembers(ctx context.Context, query *usernaut return r.deduplicateMembers(queryMembers), nil } -func extractManagerUIDsFromQuery(query *usernautdevv1alpha1.LDAPQuery) []string { +// replaceManagerInFilters returns a copy of filters where every manager filter value +// is replaced with managerUID. Duplicate manager filters with the same criteria and +// value after replacement are collapsed to a single entry. +func replaceManagerInFilters(filters []usernautdevv1alpha1.LDAPFilter, managerUID string) []usernautdevv1alpha1.LDAPFilter { + if len(filters) == 0 { + return []usernautdevv1alpha1.LDAPFilter{} + } + result := make([]usernautdevv1alpha1.LDAPFilter, 0, len(filters)) + seenManagers := make(map[string]struct{}) + for _, filter := range filters { + if filter.LDAPQuery != nil { + nested := replaceManagerInQuery(filter.LDAPQuery, managerUID) + result = append(result, usernautdevv1alpha1.LDAPFilter{ + LDAPQuery: nested, + }) + continue + } + + value := filter.Value + if strings.EqualFold(strings.TrimSpace(filter.Key), "manager") { + value = managerUID + dedupeKey := strings.ToLower(strings.TrimSpace(filter.Criteria)) + "|" + value + if _, ok := seenManagers[dedupeKey]; ok { + continue + } + seenManagers[dedupeKey] = struct{}{} + } + result = append(result, usernautdevv1alpha1.LDAPFilter{ + Key: filter.Key, + Criteria: filter.Criteria, + Value: value, + }) + } + return result +} + +func replaceManagerInQuery(query *usernautdevv1alpha1.LDAPQuery, managerUID string) *usernautdevv1alpha1.LDAPQuery { if query == nil { return nil } + return &usernautdevv1alpha1.LDAPQuery{ + Operator: query.Operator, + Filters: replaceManagerInFilters(query.Filters, managerUID), + Options: query.Options, + } +} + +func filtersHaveManager(filters []usernautdevv1alpha1.LDAPFilter) bool { + for _, filter := range filters { + if strings.EqualFold(strings.TrimSpace(filter.Key), "manager") { + return true + } + if filter.LDAPQuery != nil && queryHasManagerFilter(filter.LDAPQuery) { + return true + } + } + return false +} - managerUIDs := make([]string, 0) +// queryHasManagerFilter reports whether a manager filter exists anywhere in the query tree. +func queryHasManagerFilter(query *usernautdevv1alpha1.LDAPQuery) bool { + if query == nil { + return false + } + return filtersHaveManager(query.Filters) +} + +// extractManagerUIDsFromQuery returns unique manager UIDs referenced anywhere in the query tree. +func extractManagerUIDsFromQuery(query *usernautdevv1alpha1.LDAPQuery) []string { + if query == nil { + return []string{} + } seen := make(map[string]struct{}) - for _, filter := range query.Filters { + result := []string{} + collectManagerUIDsFromFilters(query.Filters, seen, &result) + return result +} + +func collectManagerUIDsFromFilters(filters []usernautdevv1alpha1.LDAPFilter, seen map[string]struct{}, result *[]string) { + for _, filter := range filters { + if filter.LDAPQuery != nil { + collectManagerUIDsFromFilters(filter.LDAPQuery.Filters, seen, result) + continue + } if !strings.EqualFold(strings.TrimSpace(filter.Key), "manager") { continue } - uid := filter.Value if uid == "" { continue @@ -352,10 +411,8 @@ func extractManagerUIDsFromQuery(query *usernautdevv1alpha1.LDAPQuery) []string continue } seen[uid] = struct{}{} - managerUIDs = append(managerUIDs, uid) + *result = append(*result, uid) } - - return managerUIDs } // fetchLDAPData fetches LDAP data for all unique members and populates allLdapUserData. diff --git a/internal/controller/group_controller_manager_query_test.go b/internal/controller/group_controller_manager_query_test.go new file mode 100644 index 00000000..ee4d00b5 --- /dev/null +++ b/internal/controller/group_controller_manager_query_test.go @@ -0,0 +1,262 @@ +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + usernautdevv1alpha1 "github.com/redhat-data-and-ai/usernaut/api/v1alpha1" +) + +func TestReplaceManagerInFilters(t *testing.T) { + t.Parallel() + + filters := []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "title", Criteria: "contains", Value: "engineer"}, + } + + got := replaceManagerInFilters(filters, "newMgr") + + require.Len(t, got, 2) + assert.Equal(t, "newMgr", got[0].Value) + assert.Equal(t, "engineer", got[1].Value) + assert.Equal(t, "title", got[1].Key) +} + +func TestReplaceManagerInFilters_DeduplicatesMatchingManagers(t *testing.T) { + t.Parallel() + + filters := []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + } + + got := replaceManagerInFilters(filters, "newMgr") + + require.Len(t, got, 1) + assert.Equal(t, "manager", got[0].Key) + assert.Equal(t, "equals", got[0].Criteria) + assert.Equal(t, "newMgr", got[0].Value) +} + +func TestReplaceManagerInFilters_PreservesDistinctManagerCriteria(t *testing.T) { + t.Parallel() + + filters := []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "manager", Criteria: "contains", Value: "mgrAlpha"}, + } + + got := replaceManagerInFilters(filters, "newMgr") + + require.Len(t, got, 2) + assert.Equal(t, "equals", got[0].Criteria) + assert.Equal(t, "contains", got[1].Criteria) + assert.Equal(t, "newMgr", got[0].Value) + assert.Equal(t, "newMgr", got[1].Value) +} + +func TestReplaceManagerInFilters_NestedQuery(t *testing.T) { + t.Parallel() + + filters := []usernautdevv1alpha1.LDAPFilter{ + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "or", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + }, + }, + }, + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "and", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrGamma"}, + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "or", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrDelta"}, + {Key: "co", Criteria: "equals", Value: "US"}, + }, + }, + }, + }, + }, + }, + } + + got := replaceManagerInFilters(filters, "newMgr") + require.Len(t, got, 2) + + require.NotNil(t, got[0].LDAPQuery) + assert.Len(t, got[0].LDAPQuery.Filters, 1) + assert.Equal(t, "newMgr", got[0].LDAPQuery.Filters[0].Value) + + require.NotNil(t, got[1].LDAPQuery) + assert.Equal(t, "newMgr", got[1].LDAPQuery.Filters[0].Value) + require.NotNil(t, got[1].LDAPQuery.Filters[1].LDAPQuery) + assert.Equal(t, "newMgr", got[1].LDAPQuery.Filters[1].LDAPQuery.Filters[0].Value) + assert.Equal(t, "US", got[1].LDAPQuery.Filters[1].LDAPQuery.Filters[1].Value) +} + +func TestReplaceManagerInFilters_CollapsesDuplicateManagersInNestedQuery(t *testing.T) { + t.Parallel() + + filters := []usernautdevv1alpha1.LDAPFilter{ + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "or", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + }, + }, + }, + } + + got := replaceManagerInFilters(filters, "newMgr") + require.Len(t, got, 1) + require.NotNil(t, got[0].LDAPQuery) + require.Len(t, got[0].LDAPQuery.Filters, 1) + assert.Equal(t, "newMgr", got[0].LDAPQuery.Filters[0].Value) +} + +func TestQueryHasManagerFilter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query *usernautdevv1alpha1.LDAPQuery + want bool + }{ + { + name: "nil query", + query: nil, + want: false, + }, + { + name: "top level manager", + query: &usernautdevv1alpha1.LDAPQuery{ + Operator: "and", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + }, + }, + want: true, + }, + { + name: "nested manager only", + query: &usernautdevv1alpha1.LDAPQuery{ + Operator: "and", + Filters: []usernautdevv1alpha1.LDAPFilter{ + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "or", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "engineer"}, + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "and", + Filters: []usernautdevv1alpha1.LDAPFilter{ + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "or", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: true, + }, + { + name: "no manager anywhere", + query: &usernautdevv1alpha1.LDAPQuery{ + Operator: "and", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "engineer"}, + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, queryHasManagerFilter(tt.query)) + }) + } +} + +func TestExtractManagerUIDsFromQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query *usernautdevv1alpha1.LDAPQuery + want []string + }{ + { + name: "nil query", + query: nil, + want: []string{}, + }, + { + name: "top level only", + query: &usernautdevv1alpha1.LDAPQuery{ + Operator: "or", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + }, + }, + want: []string{"mgrAlpha", "mgrBeta"}, + }, + { + name: "nested and deduplicated", + query: &usernautdevv1alpha1.LDAPQuery{ + Operator: "and", + Filters: []usernautdevv1alpha1.LDAPFilter{ + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "or", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + { + LDAPQuery: &usernautdevv1alpha1.LDAPQuery{ + Operator: "and", + Filters: []usernautdevv1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + }, + }, + }, + }, + }, + }, + }, + }, + want: []string{"mgrAlpha", "mgrBeta"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, extractManagerUIDsFromQuery(tt.query)) + }) + } +} diff --git a/pkg/clients/ldap/query.go b/pkg/clients/ldap/query.go index d3e3abc9..10055a37 100644 --- a/pkg/clients/ldap/query.go +++ b/pkg/clients/ldap/query.go @@ -83,38 +83,52 @@ func (l *LDAPConn) BuildLDAPQueryFromSpec(ctx context.Context, query *v1alpha1.L if query == nil { return "", errors.New("ldap query is nil") } + return buildQueryFromSpec(query, l.baseUserDN, 1) +} + +func buildQueryFromSpec(query *v1alpha1.LDAPQuery, baseUserDN string, depth int) (string, error) { + if depth > v1alpha1.MaxLDAPQueryDepth { + return "", fmt.Errorf("ldap query nesting exceeds maximum depth of %d", v1alpha1.MaxLDAPQueryDepth) + } if len(query.Filters) == 0 { return "", errors.New("filters are empty") } - filters, err := buildFiltersFromSpec(query.Filters, l.baseUserDN) - if err != nil { - return "", err + + parts := make([]string, 0, len(query.Filters)) + for i, filter := range query.Filters { + part, err := buildFilterItem(filter, baseUserDN, depth) + if err != nil { + return "", fmt.Errorf("filters[%d]: %w", i, err) + } + parts = append(parts, part) } op := strings.ToLower(strings.TrimSpace(query.Operator)) switch op { case "and": - return "(&" + strings.Join(filters, "") + ")", nil + return "(&" + strings.Join(parts, "") + ")", nil case "or": - return "(|" + strings.Join(filters, "") + ")", nil + return "(|" + strings.Join(parts, "") + ")", nil default: return "", fmt.Errorf("unsupported operator %q", query.Operator) } } -func buildFiltersFromSpec(filters []v1alpha1.LDAPFilter, baseUserDN string) ([]string, error) { - results := make([]string, 0, len(filters)) - for _, filter := range filters { - result, err := buildFilterFromSpec(filter, baseUserDN) - if err != nil { - return nil, err - } - results = append(results, result) +func buildFilterItem(filter v1alpha1.LDAPFilter, baseUserDN string, depth int) (string, error) { + hasSimple := filter.Key != "" || filter.Criteria != "" || filter.Value != "" + hasNested := filter.LDAPQuery != nil + + if hasSimple && hasNested { + return "", errors.New("filter item cannot have both key/criteria/value and ldap_query") + } + + if hasNested { + return buildQueryFromSpec(filter.LDAPQuery, baseUserDN, depth+1) } - return results, nil + return buildSimpleFilter(filter, baseUserDN) } -func buildFilterFromSpec(filter v1alpha1.LDAPFilter, baseUserDN string) (string, error) { +func buildSimpleFilter(filter v1alpha1.LDAPFilter, baseUserDN string) (string, error) { op := strings.ToLower(strings.TrimSpace(filter.Criteria)) if baseUserDN == "" { diff --git a/pkg/clients/ldap/query_test.go b/pkg/clients/ldap/query_test.go index 0613430d..6a2e1554 100644 --- a/pkg/clients/ldap/query_test.go +++ b/pkg/clients/ldap/query_test.go @@ -186,7 +186,7 @@ func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_AndOperator() { { Key: "manager", Criteria: "equals", - Value: "pbhattac", + Value: "mgrAlpha", }, { Key: "title", @@ -199,7 +199,7 @@ func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_AndOperator() { filter, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) assertions.NoError(err) - assertions.Equal("(&(manager=uid=pbhattac,ou=users,dc=redhat,dc=com)(title=*senior*))", filter) + assertions.Equal("(&(manager=uid=mgrAlpha,ou=users,dc=redhat,dc=com)(title=*senior*))", filter) } func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_OrOperator() { @@ -215,12 +215,12 @@ func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_OrOperator() { { Key: "manager", Criteria: "equals", - Value: "zzhou", + Value: "mgrBeta", }, { Key: "manager", Criteria: "equals", - Value: "robwilli", + Value: "mgrGamma", }, }, } @@ -228,7 +228,7 @@ func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_OrOperator() { assertions.NoError(err) assertions.Equal( - "(|(manager=uid=zzhou,ou=users,dc=redhat,dc=com)(manager=uid=robwilli,ou=users,dc=redhat,dc=com))", + "(|(manager=uid=mgrBeta,ou=users,dc=redhat,dc=com)(manager=uid=mgrGamma,ou=users,dc=redhat,dc=com))", filter, ) } @@ -270,7 +270,7 @@ func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_MixOperator() { { Key: "manager", Criteria: "equals", - Value: "ticramer", + Value: "mgrDelta", }, { Key: "employeeType", @@ -283,5 +283,341 @@ func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_MixOperator() { filter, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) assertions.NoError(err) - assertions.Equal("(&(manager=uid=ticramer,ou=users,dc=redhat,dc=com)(!(employeeType=external employee)))", filter) + assertions.Equal("(&(manager=uid=mgrDelta,ou=users,dc=redhat,dc=com)(!(employeeType=external employee)))", filter) +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_NestedOrInsideAnd() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + { + Key: "employeeType", + Criteria: "not", + Value: "external employee", + }, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "engineer"}, + {Key: "title", Criteria: "contains", Value: "developer"}, + {Key: "title", Criteria: "contains", Value: "architect"}, + }, + }, + }, + }, + } + + filter, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + + assertions.NoError(err) + assertions.Equal( + "(&(!(employeeType=external employee))(|(title=*engineer*)(title=*developer*)(title=*architect*)))", + filter, + ) +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_NestedAndInsideOr() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "engineer"}, + {Key: "co", Criteria: "equals", Value: "US"}, + }, + }, + }, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "developer"}, + {Key: "co", Criteria: "equals", Value: "IND"}, + }, + }, + }, + }, + } + + filter, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + + assertions.NoError(err) + assertions.Equal( + "(|(&(title=*engineer*)(co=US))(&(title=*developer*)(co=IND)))", + filter, + ) +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_MultipleNestedQueries() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "employeeType", Criteria: "not", Value: "external employee"}, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "engineer"}, + {Key: "title", Criteria: "contains", Value: "developer"}, + }, + }, + }, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + }, + }, + }, + }, + } + + filter, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + + assertions.NoError(err) + expected := "(&(!(employeeType=external employee))" + + "(|(title=*engineer*)(title=*developer*))" + + "(|(manager=uid=mgrAlpha,ou=users,dc=redhat,dc=com)" + + "(manager=uid=mgrBeta,ou=users,dc=redhat,dc=com)))" + assertions.Equal(expected, filter) +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_QueriesOnly() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrAlpha"}, + }, + }, + }, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "manager", Criteria: "equals", Value: "mgrBeta"}, + }, + }, + }, + }, + } + + filter, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + + assertions.NoError(err) + assertions.Equal( + "(|(&(manager=uid=mgrAlpha,ou=users,dc=redhat,dc=com))(&(manager=uid=mgrBeta,ou=users,dc=redhat,dc=com)))", + filter, + ) +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_EmptyFiltersAndQueries() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "and", + } + + _, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + assertions.Error(err) + assertions.Contains(err.Error(), "filters are empty") +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_InvalidNestedOperator() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "xor", + Filters: []v1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "engineer"}, + }, + }, + }, + }, + } + + _, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + assertions.Error(err) + assertions.Contains(err.Error(), "unsupported operator") +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_EmptyNestedFilters() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{}, + }, + }, + }, + } + + _, err := ldapConn.BuildLDAPQueryFromSpec(context.Background(), query) + assertions.Error(err) + assertions.Contains(err.Error(), "filters are empty") +} + +// TestBuildLDAPQueryFromSpec_BothSimpleAndNestedOnSameFilter verifies buildFilterItem rejects +// a filter item that sets both key/criteria/value and ldap_query. +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_BothSimpleAndNestedOnSameFilter() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + { + Key: "title", + Criteria: "contains", + Value: "engineer", + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + {Key: "rhatGeo", Criteria: "equals", Value: "APAC"}, + }, + }, + }, + }, + } + + _, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + assertions.Error(err) + assertions.Contains(err.Error(), "filter item cannot have both key/criteria/value and ldap_query") +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_FourLevelNesting() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + query := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "employeeType", Criteria: "not", Value: "external employee"}, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + {Key: "title", Criteria: "contains", Value: "engineer"}, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "co", Criteria: "equals", Value: "US"}, + { + LDAPQuery: &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{ + {Key: "rhatCostCenter", Criteria: "equals", Value: "123"}, + {Key: "rhatCostCenter", Criteria: "equals", Value: "456"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + filter, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + assertions.NoError(err) + assertions.Equal( + "(&(!(employeeType=external employee))(|(title=*engineer*)(&(co=US)(|(rhatCostCenter=123)(rhatCostCenter=456)))))", + filter, + ) +} + +func (suite *LDAPTestSuite) TestBuildLDAPQueryFromSpec_ExceedsMaxDepth() { + assertions := assert.New(suite.T()) + + ldapConn := &LDAPConn{ + baseUserDN: "ou=users,dc=redhat,dc=com", + } + + level4 := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{ + {Key: "co", Criteria: "equals", Value: "US"}, + }, + } + level3 := &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{{LDAPQuery: level4}}, + } + level2 := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{{LDAPQuery: level3}}, + } + level1 := &v1alpha1.LDAPQuery{ + Operator: "or", + Filters: []v1alpha1.LDAPFilter{{LDAPQuery: level2}}, + } + query := &v1alpha1.LDAPQuery{ + Operator: "and", + Filters: []v1alpha1.LDAPFilter{{LDAPQuery: level1}}, + } + + _, err := ldapConn.BuildLDAPQueryFromSpec(suite.ctx, query) + assertions.Error(err) + assertions.Contains(err.Error(), "exceeds maximum depth") }