[ 
https://issues.apache.org/jira/browse/KNOX-3487?focusedWorklogId=1047847&page=com.atlassian.jira.plugin.system.issuetabpanels:worklog-tabpanel#worklog-1047847
 ]

ASF GitHub Bot logged work on KNOX-3487:
----------------------------------------

                Author: ASF GitHub Bot
            Created on: 16/Oct/26 13:41
            Start Date: 16/Oct/26 13:41
    Worklog Time Spent: 10m 
      Work Description: smolnar82 commented on code in PR #1454:
URL: https://github.com/apache/knox/pull/1454#discussion_r4294989077


##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java:
##########
@@ -80,6 +83,8 @@ public class LdapProxyBackend implements LdapBackend {
     private static final String[] ALL_ATTRIBUTES = new String[]{"*"};
     private static final String[] ALL_ATTRIBUTES_AND_MEMBEROF = new 
String[]{"*", "memberOf"};
 
+    private static final String DEFAULT_MAX_FILTER_LENGTH = "2000";

Review Comment:
   - `buildMultipleGroupMemberFilters` also splits the existing 
group-membership resolution, not only the new memberOf expansion. At roughly 
150 characters per POSIX clause, every `useMemberOf=false` deployment makes one 
extra round trip for about every 13 users.
   - Real server limits are far higher (OpenLDAP 256KB anonymous / 4MB 
authenticated, AD 10MB).
   
   I'd default to `0` (unlimited) or to something much larger.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptor.java:
##########
@@ -86,6 +96,90 @@ public EntryFilteringCursor search(SearchOperationContext 
ctx) throws LdapExcept
             }
         }
 
+        if (isAllGroupsSearch(ctx)) {
+            return handleAllGroupsSearch(ctx);
+        } else {
+            return handleSearch(ctx);
+        }
+    }
+
+    private boolean isAllGroupsSearch(SearchOperationContext ctx) {
+        Dn searchDn = ctx.getDn();
+        if (searchDn == null) {
+            return false;
+        }
+
+        if (ctx.getScope() == SearchScope.OBJECT || 
!isAllGroupsFilter(ctx.getFilter())) {
+            return false;
+        }
+        // Either the proxy groups base, or the root with an explicit group 
filter
+        return searchDn.toString().equalsIgnoreCase(proxyGroupSearchBase) || 
searchDn.size() <= 2;

Review Comment:
   The comment's intent ("the proxy groups base, or the root with an explicit 
group filter") makes sense, but the code doesn't enforce either half:
   
   - `searchDn.size() <= 2` only means "root" when the base DN has exactly two 
RDNs. With `gateway.ldap.base.dn=o=acme`, `ou=users,o=acme` also matches, so a 
user search with `(cn=*)` returns roles. With `dc=acme,dc=co,dc=uk`, the root 
never matches. Please compare normalized `Dn`s against the configured base DN 
and `ou=groups,<baseDn>`, which also removes the spacing problem with 
`toString().equalsIgnoreCase`.
   - `isAllGroupsFilter` accepts `(cn=*)` and `(objectClass=*)`, which aren't 
explicit group filters. At the root, the default `ldapsearch -b 
dc=proxy,dc=com` uses `(objectClass=*)` and now returns only roles, with no 
users. Please accept those two filters only when the base is 
`ou=groups,<baseDn>`, and require `objectClass=groupOfNames|groupOfUniqueNames` 
at the root.
   - Minor: a ONELEVEL search at the root returns roles, which sit one level 
lower, under `ou=groups`. With `objectClass=*` accepted, this breaks tree 
browsing in LDAP browsers.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptor.java:
##########
@@ -86,6 +96,90 @@ public EntryFilteringCursor search(SearchOperationContext 
ctx) throws LdapExcept
             }
         }
 
+        if (isAllGroupsSearch(ctx)) {
+            return handleAllGroupsSearch(ctx);
+        } else {
+            return handleSearch(ctx);
+        }
+    }
+
+    private boolean isAllGroupsSearch(SearchOperationContext ctx) {
+        Dn searchDn = ctx.getDn();
+        if (searchDn == null) {
+            return false;
+        }
+
+        if (ctx.getScope() == SearchScope.OBJECT || 
!isAllGroupsFilter(ctx.getFilter())) {
+            return false;
+        }
+        // Either the proxy groups base, or the root with an explicit group 
filter
+        return searchDn.toString().equalsIgnoreCase(proxyGroupSearchBase) || 
searchDn.size() <= 2;
+    }
+
+    private boolean isAllGroupsFilter(ExprNode filter) {
+        // Case 1: Simple objectClass evaluation (e.g., 
objectClass=groupOfNames)
+        if (filter instanceof EqualityNode) {
+            EqualityNode<?> eqNode = (EqualityNode<?>) filter;
+            if ("objectClass".equalsIgnoreCase(eqNode.getAttribute())) {
+                String value = eqNode.getValue().toString();
+                return "groupOfNames".equalsIgnoreCase(value) ||
+                        "groupOfUniqueNames".equalsIgnoreCase(value);
+            }
+        }
+
+        // Case 2a: Wildcard match on all common names and object classes 
(e.g., cn=*, objectClass=*)
+        if (filter instanceof PresenceNode) {
+            PresenceNode presNode = (PresenceNode) filter;
+            return "cn".equalsIgnoreCase(presNode.getAttribute()) ||
+                    "objectClass".equalsIgnoreCase(presNode.getAttribute());
+        }
+        // Case 2b: Wildcard match on object class (e.g., objectClass=*)
+        if (filter instanceof ObjectClassNode) {
+            return true;
+        }
+
+        // Case 3: Compound filters. An AND is an all groups filter only if 
every child is, an OR
+        // if any child is, e.g. (&(objectClass=groupOfNames)(cn=*)) or 
(|(uid=alice)(cn=*)).
+        // A NOT is never an all groups filter: negating a condition never 
positively selects groups.
+        if (filter instanceof AndNode) {
+            List<ExprNode> children = ((AndNode) filter).getChildren();
+            return !children.isEmpty() && 
children.stream().allMatch(this::isAllGroupsFilter);
+        }
+        if (filter instanceof OrNode) {
+            return ((OrNode) 
filter).getChildren().stream().anyMatch(this::isAllGroupsFilter);

Review Comment:
   With "any child matches", `(|(uid=alice)(cn=*))` is treated as all-groups 
and `alice` is dropped. An OR filter only selects all groups if every child 
does, so this should be `allMatch`.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/roles/rest/RestApiLdapRolesReverseLookup.java:
##########
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.services.ldap.roles.rest;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.ldap.LdapMessages;
+import org.apache.knox.gateway.services.ldap.RoleAssignees;
+import org.apache.knox.gateway.services.ldap.RoleAssignment;
+import org.apache.knox.gateway.services.ldap.roles.LdapRolesReverseLookup;
+import org.apache.knox.gateway.services.ldap.roles.RoleException;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * REST API based implementation of LdapRolesReverseLookup.
+ */
+public class RestApiLdapRolesReverseLookup implements LdapRolesReverseLookup {
+    private static final LdapMessages LOG = 
MessagesFactory.get(LdapMessages.class);
+
+    private final ObjectMapper mapper = new ObjectMapper();
+    private final String listEndpoint;
+    private final String getEndpoint;
+
+    public RestApiLdapRolesReverseLookup(String listEndpoint, String 
getEndpoint) {
+        this.listEndpoint = listEndpoint;
+        this.getEndpoint = getEndpoint;
+    }
+
+    @Override
+    public Collection<String> listRoles() throws RoleException {
+        final List<Role> roles = listRoles(null);
+        Set<String> result = new HashSet<>();
+        for (Role role : roles) {
+            result.add(new RoleAssignment(role.getScope(), 
role.getName()).getDisplayValue());
+        }
+        return result;
+    }
+
+    @Override
+    public RoleAssignees lookupRoleAssignees(String role) throws RoleException 
{
+        if (role != null) {
+            RoleAssignment roleAssignment = 
RoleAssignment.createFromDisplayValue(role);
+            List<Role> rolesList = listRoles(roleAssignment);
+            String roleId = findRoleId(roleAssignment, rolesList);
+            if (roleId != null) {
+                List<String> users = new ArrayList<>();
+                List<String> groups = new ArrayList<>();
+                List<RoleMember> members = getMembers(roleId);
+                if (members != null) {
+                    for (RoleMember member : members) {
+                        if (member.getMemberType().equalsIgnoreCase("user")) {
+                            users.add(member.getMemberId());
+                        } else if 
(member.getMemberType().equalsIgnoreCase("group")) {
+                            groups.add(member.getMemberId());
+                        } else {
+                            // ignore unknown member types
+                            LOG.restRolesAssigneeTypeUnknown(role, 
member.getMemberType());
+                        }
+                    }
+                }
+                return new RoleAssignees(users, groups);
+            }
+        }
+        return new RoleAssignees(List.of(), List.of());
+    }
+
+    private List<RoleMember> getMembers(String roleId) throws RoleException {
+        String encodedRoleId = URLEncoder.encode(roleId, 
StandardCharsets.UTF_8)
+                .replace("+", "%20"); // URLEncoder converts spaces to '+', 
path params need '%20'
+        String getUrl = getEndpoint + "/" + encodedRoleId;
+
+        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+            LOG.restRolesGetRequest(getUrl, roleId);
+            HttpGet getRequest = new HttpGet(getUrl);
+            getRequest.setHeader("Accept", "application/json");
+            try (CloseableHttpResponse response = 
httpClient.execute(getRequest)) {
+                int statusCode = response.getStatusLine().getStatusCode();
+                HttpEntity entity = response.getEntity();
+                final String jsonResponse = entity == null ? null : 
EntityUtils.toString(entity);
+                LOG.restRolesGetResponse(getUrl, statusCode, jsonResponse);
+
+                if (statusCode != 200) {
+                    LOG.restRolesLookupHttpError(getUrl, statusCode, 
jsonResponse);

Review Comment:
   The new `restRolesGetHttpError` and `restRolesListHttpError` messages aren't 
used, so get and list errors are logged as lookup errors 
(`restRolesLookupHttpError`).
   The same applies at line 162 (`listRoles`).



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java:
##########
@@ -576,6 +586,54 @@ public List<Entry> search(String searchBase, SearchScope 
searchScope, String fil
         }
     }
 
+    private String mapMemberOfExpression(String filter, SchemaManager 
schemaManager) throws Exception {
+        if (useMemberOf || !filter.toLowerCase(ROOT).contains("memberof")) {
+            return filter;
+        }
+        // Convert filter if it contains "memberOf" but the backend doesn't 
support "memberOf"
+        MemberOfMappingVisitor visitor = new 
MemberOfMappingVisitor(schemaManager, this::lookupGroup, this::isUser);
+        ExprNode node = FilterParser.parse(schemaManager, filter);
+        // Baseline in the same form as the visitor output so an unchanged 
visit exits on the first pass
+        String current = node.toString();
+        for (int i = 0; i < recursiveGroupResolutionMaxDepth; ++i) {

Review Comment:
   This loop always runs up to `recursiveGroupResolutionMaxDepth` (default 3), 
even when `recursiveGroupResolution` is off, which is the default. Please do a 
single pass when recursion is disabled.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/interceptor/LDAPRolesLookupInterceptor.java:
##########
@@ -86,6 +96,90 @@ public EntryFilteringCursor search(SearchOperationContext 
ctx) throws LdapExcept
             }
         }
 
+        if (isAllGroupsSearch(ctx)) {
+            return handleAllGroupsSearch(ctx);
+        } else {
+            return handleSearch(ctx);
+        }
+    }
+
+    private boolean isAllGroupsSearch(SearchOperationContext ctx) {
+        Dn searchDn = ctx.getDn();
+        if (searchDn == null) {
+            return false;
+        }
+
+        if (ctx.getScope() == SearchScope.OBJECT || 
!isAllGroupsFilter(ctx.getFilter())) {
+            return false;
+        }
+        // Either the proxy groups base, or the root with an explicit group 
filter
+        return searchDn.toString().equalsIgnoreCase(proxyGroupSearchBase) || 
searchDn.size() <= 2;
+    }
+
+    private boolean isAllGroupsFilter(ExprNode filter) {
+        // Case 1: Simple objectClass evaluation (e.g., 
objectClass=groupOfNames)
+        if (filter instanceof EqualityNode) {
+            EqualityNode<?> eqNode = (EqualityNode<?>) filter;
+            if ("objectClass".equalsIgnoreCase(eqNode.getAttribute())) {
+                String value = eqNode.getValue().toString();
+                return "groupOfNames".equalsIgnoreCase(value) ||
+                        "groupOfUniqueNames".equalsIgnoreCase(value);
+            }
+        }
+
+        // Case 2a: Wildcard match on all common names and object classes 
(e.g., cn=*, objectClass=*)
+        if (filter instanceof PresenceNode) {
+            PresenceNode presNode = (PresenceNode) filter;
+            return "cn".equalsIgnoreCase(presNode.getAttribute()) ||
+                    "objectClass".equalsIgnoreCase(presNode.getAttribute());
+        }
+        // Case 2b: Wildcard match on object class (e.g., objectClass=*)
+        if (filter instanceof ObjectClassNode) {
+            return true;
+        }
+
+        // Case 3: Compound filters. An AND is an all groups filter only if 
every child is, an OR
+        // if any child is, e.g. (&(objectClass=groupOfNames)(cn=*)) or 
(|(uid=alice)(cn=*)).
+        // A NOT is never an all groups filter: negating a condition never 
positively selects groups.
+        if (filter instanceof AndNode) {
+            List<ExprNode> children = ((AndNode) filter).getChildren();
+            return !children.isEmpty() && 
children.stream().allMatch(this::isAllGroupsFilter);
+        }
+        if (filter instanceof OrNode) {
+            return ((OrNode) 
filter).getChildren().stream().anyMatch(this::isAllGroupsFilter);
+        }
+
+        return false;
+    }
+
+    private EntryFilteringCursor handleAllGroupsSearch(SearchOperationContext 
ctx) throws LdapException {
+        // short-circuit the LDAP search to return all roles
+        Collection<String> roles = null;
+        try {
+            roles = rolesLookupService.listRoles();
+        } catch (Exception e) {
+            throw new LdapException("Failed to retrieve roles from roles 
lookup service.", e);
+        }
+        List<Entry> entries = new ArrayList<>();
+        for (String role : roles) {
+            Dn roleDn = new Dn(schemaManager, createGroupDnFromName(role));
+            Entry entry = createRoleEntry(roleDn);
+            entries.add(entry);
+        }
+
+        return new EntryFilteringCursorImpl(new ListCursor<>(entries), ctx, 
schemaManager);
+    }
+
+    private EntryFilteringCursor handleSearch(SearchOperationContext ctx) 
throws LdapException {
+        // Replace the role dn with the member dns if the search filter 
contains a "memberOf" node.
+        ExprNode filter = ctx.getFilter();
+        ReverseRoleLookupMappingVisitor reverseRoleLookupMappingVisitor =
+                new ReverseRoleLookupMappingVisitor(schemaManager, 
rolesLookupService, this::createGroupDnFromName);
+        Object updatedFilter = filter.accept(reverseRoleLookupMappingVisitor);

Review Comment:
   If the roles service fails, a DN can't be parsed, or 
`RoleAssignment.createFromDisplayValue` throws on `a:b:c`, the visitor throws 
an unchecked exception (`RuntimeException` from 
`ReverseRoleLookupMappingVisitor.java:64`, or `IllegalArgumentException`). This 
call isn't inside a try/catch, so the exception escapes, the client doesn't get 
an `LdapException`, and `ldapRolesLookupFailed` is never logged.
   
   Please catch the exception here and convert it to an `LdapException`. The 
same applies to `MemberOfMappingVisitor.java:66` as called from 
`LdapProxyBackend.mapMemberOfExpression`.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java:
##########
@@ -576,6 +586,54 @@ public List<Entry> search(String searchBase, SearchScope 
searchScope, String fil
         }
     }
 
+    private String mapMemberOfExpression(String filter, SchemaManager 
schemaManager) throws Exception {
+        if (useMemberOf || !filter.toLowerCase(ROOT).contains("memberof")) {
+            return filter;
+        }
+        // Convert filter if it contains "memberOf" but the backend doesn't 
support "memberOf"
+        MemberOfMappingVisitor visitor = new 
MemberOfMappingVisitor(schemaManager, this::lookupGroup, this::isUser);
+        ExprNode node = FilterParser.parse(schemaManager, filter);

Review Comment:
   This parses the raw filter before `convertProxyFilterToRemoteFilter` 
(`RemoteSchemaConverter.java:151`) removes the `:[...]` annotations, and that 
method's comment says those annotations occur in practice. Please apply the 
same cleanup first, ideally through a shared helper. It's worth testing with a 
real annotated filter.



##########
gateway-spi/src/main/java/org/apache/knox/gateway/services/ldap/RoleAssignment.java:
##########
@@ -20,6 +20,18 @@
 import com.fasterxml.jackson.annotation.JsonProperty;
 
 public class RoleAssignment {
+    public static RoleAssignment createFromDisplayValue(String displayValue) {
+        String[] tokens = displayValue.split(":");

Review Comment:
   Please use `split(":", 2)` so a role name that contains `:` (for example 
`platform:team:admin`) is parsed as scope `platform` and name `team:admin`. 
Right now it throws `IllegalArgumentException` at line 31, and with the 
unhandled-exception issue in `handleSearch` that error escapes the LDAP search 
as an unchecked exception.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/roles/rest/RestApiLdapRolesReverseLookup.java:
##########
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.services.ldap.roles.rest;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.ldap.LdapMessages;
+import org.apache.knox.gateway.services.ldap.RoleAssignees;
+import org.apache.knox.gateway.services.ldap.RoleAssignment;
+import org.apache.knox.gateway.services.ldap.roles.LdapRolesReverseLookup;
+import org.apache.knox.gateway.services.ldap.roles.RoleException;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * REST API based implementation of LdapRolesReverseLookup.
+ */
+public class RestApiLdapRolesReverseLookup implements LdapRolesReverseLookup {
+    private static final LdapMessages LOG = 
MessagesFactory.get(LdapMessages.class);
+
+    private final ObjectMapper mapper = new ObjectMapper();
+    private final String listEndpoint;
+    private final String getEndpoint;
+
+    public RestApiLdapRolesReverseLookup(String listEndpoint, String 
getEndpoint) {
+        this.listEndpoint = listEndpoint;
+        this.getEndpoint = getEndpoint;
+    }
+
+    @Override
+    public Collection<String> listRoles() throws RoleException {
+        final List<Role> roles = listRoles(null);
+        Set<String> result = new HashSet<>();
+        for (Role role : roles) {
+            result.add(new RoleAssignment(role.getScope(), 
role.getName()).getDisplayValue());
+        }
+        return result;
+    }
+
+    @Override
+    public RoleAssignees lookupRoleAssignees(String role) throws RoleException 
{
+        if (role != null) {
+            RoleAssignment roleAssignment = 
RoleAssignment.createFromDisplayValue(role);
+            List<Role> rolesList = listRoles(roleAssignment);
+            String roleId = findRoleId(roleAssignment, rolesList);
+            if (roleId != null) {
+                List<String> users = new ArrayList<>();
+                List<String> groups = new ArrayList<>();
+                List<RoleMember> members = getMembers(roleId);
+                if (members != null) {
+                    for (RoleMember member : members) {
+                        if (member.getMemberType().equalsIgnoreCase("user")) {
+                            users.add(member.getMemberId());
+                        } else if 
(member.getMemberType().equalsIgnoreCase("group")) {
+                            groups.add(member.getMemberId());
+                        } else {
+                            // ignore unknown member types
+                            LOG.restRolesAssigneeTypeUnknown(role, 
member.getMemberType());
+                        }
+                    }
+                }
+                return new RoleAssignees(users, groups);
+            }
+        }
+        return new RoleAssignees(List.of(), List.of());
+    }
+
+    private List<RoleMember> getMembers(String roleId) throws RoleException {
+        String encodedRoleId = URLEncoder.encode(roleId, 
StandardCharsets.UTF_8)
+                .replace("+", "%20"); // URLEncoder converts spaces to '+', 
path params need '%20'
+        String getUrl = getEndpoint + "/" + encodedRoleId;
+
+        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+            LOG.restRolesGetRequest(getUrl, roleId);
+            HttpGet getRequest = new HttpGet(getUrl);
+            getRequest.setHeader("Accept", "application/json");
+            try (CloseableHttpResponse response = 
httpClient.execute(getRequest)) {
+                int statusCode = response.getStatusLine().getStatusCode();
+                HttpEntity entity = response.getEntity();
+                final String jsonResponse = entity == null ? null : 
EntityUtils.toString(entity);
+                LOG.restRolesGetResponse(getUrl, statusCode, jsonResponse);
+
+                if (statusCode != 200) {
+                    LOG.restRolesLookupHttpError(getUrl, statusCode, 
jsonResponse);
+                    throw new RoleException("Failed to get roles: HTTP " + 
statusCode);
+                }
+
+                if (jsonResponse == null) {
+                    throw new RoleException("Empty response from role get 
API");
+                }
+
+                return mapper.readValue(jsonResponse, Role.class).getMembers();
+            }
+        } catch (IOException e) {
+            throw new RoleException("Error while executing role get", e);
+        }
+    }
+
+    private List<Role> listRoles(RoleAssignment roleAssignment) throws 
RoleException {
+        String listUrl = null;
+        int numRolesRetrieved = 0;
+        int totalExpectedRoles = 0;
+        List<Role> roles = new ArrayList<>();
+
+        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+            do {
+                URIBuilder uriBuilder = new URIBuilder(listEndpoint);
+                if (roleAssignment != null) {
+                    if (roleAssignment.getName() != null) {
+                        uriBuilder.addParameter("name", 
roleAssignment.getName());
+                    }
+                    if (roleAssignment.getScope() != null) {
+                        uriBuilder.addParameter("scope", 
roleAssignment.getScope());
+                    }
+                }
+                uriBuilder.addParameter("offset", 
Integer.toString(numRolesRetrieved));
+                URI listURI = uriBuilder.build();
+                listUrl = listURI.toString();
+                LOG.restRolesListRequest(listUrl);
+
+                HttpGet listRequest = new HttpGet(listUrl);
+                listRequest.setHeader("Accept", "application/json");
+                try (CloseableHttpResponse response = 
httpClient.execute(listRequest)) {
+                    int statusCode = response.getStatusLine().getStatusCode();
+                    HttpEntity entity = response.getEntity();
+                    final String jsonResponse = entity == null ? null : 
EntityUtils.toString(entity);
+                    LOG.restRolesListResponse(listUrl, statusCode, 
jsonResponse);
+
+                    if (statusCode != 200) {
+                        LOG.restRolesLookupHttpError(listUrl, statusCode, 
jsonResponse);
+                        throw new RoleException("Failed to list roles: HTTP " 
+ statusCode);
+                    }
+
+                    if (jsonResponse == null) {
+                        throw new RoleException("Empty response from role list 
API");
+                    }
+
+                    ListRolesResponse listRolesResponse = 
mapper.readValue(jsonResponse, ListRolesResponse.class);
+                    numRolesRetrieved += listRolesResponse.getData().size();

Review Comment:
   These throw an NPE when the response has no `data`, `pagination` or 
`member_type` (`getData().size()`, `getPagination().getTotal()`, 
`member.getMemberType().equalsIgnoreCase(...)`). Please null-check them and 
raise a `RoleException` with a clear message.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java:
##########
@@ -861,13 +919,49 @@ private List<Entry> getUserGroupsInternal(LdapConnection 
connection, Dn... dns)
             return groups;
         }
 
-        String filter = buildMultipleGroupMemberFilter(dns);
-
-        groups.addAll(performPagedSearch(connection, remoteGroupSearchBase, 
filter, SearchScope.SUBTREE, "cn"));
+        groups.addAll(performSplitSearch(connection, remoteGroupSearchBase, 
buildMultipleGroupMemberFilters(dns), SearchScope.SUBTREE, "cn"));
 
         return groups;
     }
 
+    /**
+     * Splits a filter that exceeds maxFilterLength into several filters whose 
combined results are
+     * equivalent to the original filter. A filter that can't be split is 
returned as is.
+     */
+    private List<String> splitFilter(String filter) throws LdapException {
+        if (maxFilterLength <= 0 || filter.length() <= maxFilterLength) {
+            return List.of(filter);
+        }
+        try {
+            // Remote attributes may be unknown to the local schema so parse 
without a schema manager
+            List<ExprNode> pieces = 
FilterSplitter.split(FilterParser.parse(filter), maxFilterLength);
+            LOG.ldapFilterSplit(filter.length(), maxFilterLength, 
pieces.size());
+            return 
pieces.stream().map(ExprNode::toString).collect(Collectors.toList());
+        } catch (java.text.ParseException e) {
+            throw new LdapException("Unable to split oversized filter: " + 
e.getMessage(), e);
+        }
+    }
+
+    /**
+     * Executes each filter as a separate paged search and combines the 
results, removing entries
+     * returned by more than one query. Stops early once maxResultSetSize 
entries have been collected.
+     */
+    protected List<Entry> performSplitSearch(LdapConnection connection, String 
baseDn, List<String> filters, SearchScope scope, String... attributes) throws 
LdapException, CursorException, IOException {
+        if (filters.size() == 1) {
+            return performPagedSearch(connection, baseDn, filters.get(0), 
scope, attributes);
+        }
+        Map<String, Entry> combined = new LinkedHashMap<>();
+        for (String filter : filters) {
+            for (Entry entry : performPagedSearch(connection, baseDn, filter, 
scope, attributes)) {
+                combined.putIfAbsent(entry.getDn().getNormName(), entry);
+            }
+            if (maxResultSetSize != 0 && combined.size() >= maxResultSetSize) {
+                break;
+            }
+        }
+        return new ArrayList<>(combined.values());

Review Comment:
   The `maxResultSetSize` check runs only after each filter piece finishes, so 
the combined result can go past the limit. Please truncate it to 
`maxResultSetSize`.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/roles/rest/RestApiLdapRolesReverseLookup.java:
##########
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.services.ldap.roles.rest;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.utils.URIBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.ldap.LdapMessages;
+import org.apache.knox.gateway.services.ldap.RoleAssignees;
+import org.apache.knox.gateway.services.ldap.RoleAssignment;
+import org.apache.knox.gateway.services.ldap.roles.LdapRolesReverseLookup;
+import org.apache.knox.gateway.services.ldap.roles.RoleException;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * REST API based implementation of LdapRolesReverseLookup.
+ */
+public class RestApiLdapRolesReverseLookup implements LdapRolesReverseLookup {
+    private static final LdapMessages LOG = 
MessagesFactory.get(LdapMessages.class);
+
+    private final ObjectMapper mapper = new ObjectMapper();
+    private final String listEndpoint;
+    private final String getEndpoint;
+
+    public RestApiLdapRolesReverseLookup(String listEndpoint, String 
getEndpoint) {
+        this.listEndpoint = listEndpoint;
+        this.getEndpoint = getEndpoint;
+    }
+
+    @Override
+    public Collection<String> listRoles() throws RoleException {
+        final List<Role> roles = listRoles(null);
+        Set<String> result = new HashSet<>();
+        for (Role role : roles) {
+            result.add(new RoleAssignment(role.getScope(), 
role.getName()).getDisplayValue());
+        }
+        return result;
+    }
+
+    @Override
+    public RoleAssignees lookupRoleAssignees(String role) throws RoleException 
{
+        if (role != null) {
+            RoleAssignment roleAssignment = 
RoleAssignment.createFromDisplayValue(role);
+            List<Role> rolesList = listRoles(roleAssignment);
+            String roleId = findRoleId(roleAssignment, rolesList);
+            if (roleId != null) {
+                List<String> users = new ArrayList<>();
+                List<String> groups = new ArrayList<>();
+                List<RoleMember> members = getMembers(roleId);
+                if (members != null) {
+                    for (RoleMember member : members) {
+                        if (member.getMemberType().equalsIgnoreCase("user")) {
+                            users.add(member.getMemberId());
+                        } else if 
(member.getMemberType().equalsIgnoreCase("group")) {
+                            groups.add(member.getMemberId());
+                        } else {
+                            // ignore unknown member types
+                            LOG.restRolesAssigneeTypeUnknown(role, 
member.getMemberType());
+                        }
+                    }
+                }
+                return new RoleAssignees(users, groups);
+            }
+        }
+        return new RoleAssignees(List.of(), List.of());
+    }
+
+    private List<RoleMember> getMembers(String roleId) throws RoleException {
+        String encodedRoleId = URLEncoder.encode(roleId, 
StandardCharsets.UTF_8)
+                .replace("+", "%20"); // URLEncoder converts spaces to '+', 
path params need '%20'
+        String getUrl = getEndpoint + "/" + encodedRoleId;

Review Comment:
   If the endpoint is configured with a trailing slash 
(`http://host/auth/roles/`), this builds `//<id>`. Please strip trailing 
slashes from the endpoint, or build the URL with `URIBuilder`.



##########
gateway-server/src/main/java/org/apache/knox/gateway/services/ldap/backend/LdapProxyBackend.java:
##########
@@ -576,6 +586,54 @@ public List<Entry> search(String searchBase, SearchScope 
searchScope, String fil
         }
     }
 
+    private String mapMemberOfExpression(String filter, SchemaManager 
schemaManager) throws Exception {
+        if (useMemberOf || !filter.toLowerCase(ROOT).contains("memberof")) {
+            return filter;
+        }
+        // Convert filter if it contains "memberOf" but the backend doesn't 
support "memberOf"
+        MemberOfMappingVisitor visitor = new 
MemberOfMappingVisitor(schemaManager, this::lookupGroup, this::isUser);
+        ExprNode node = FilterParser.parse(schemaManager, filter);
+        // Baseline in the same form as the visitor output so an unchanged 
visit exits on the first pass
+        String current = node.toString();
+        for (int i = 0; i < recursiveGroupResolutionMaxDepth; ++i) {
+            Object visitedNode = node.accept(visitor);
+            if (!(visitedNode instanceof ExprNode)) {
+                throw new Exception("Unable to transform memberOf expressions 
in filter: " + filter);
+            }
+            node = (ExprNode) visitedNode;
+            String updated = node.toString();
+            if (updated.equals(current)) {
+                break;
+            }
+            current = updated;
+        }
+        return current;
+    }
+
+    private Entry lookupGroup(String dn, SchemaManager schemaManager) throws 
Exception {
+        String remoteDn = remoteSchemaConverter.convertProxyDnToRemoteDn(dn);
+        LdapConnection connection = null;
+        try {
+            connection = getConnection();
+            Entry entry = connection.lookup(remoteDn);
+            if  (entry != null) {
+                return 
remoteSchemaConverter.convertRemoteEntryToProxyEntry(entry, schemaManager);
+            } else {
+                return null;
+            }
+        } finally {
+            releaseConnection(connection);
+        }
+    }
+
+    private boolean isUser(String userDn) {
+        if (userDn == null) {
+            return false;
+        }
+        String lowerCaseUserDn = userDn.toLowerCase(ROOT);
+        return lowerCaseUserDn.contains(proxyUserSearchBase) || 
lowerCaseUserDn.contains(remoteUserObjectClass);

Review Comment:
   This check is unreliable:
   
   - `contains(remoteUserObjectClass)`: an objectClass is never part of a DN. 
The default `inetOrgPerson` is mixed-case and is compared against a lowercased 
string, so it can never match. For AD, `user` matches substrings such as 
`cn=users` or `cn=superusers`.
   - `proxyUserSearchBase` isn't lowercased, so a base DN configured as 
`DC=proxy,DC=com` never matches.
   - With `dnMappingEnabled=false`, member DNs stay remote DNs, so they never 
contain the proxy base. Every user is then treated as a group, and the filter 
silently returns nothing.
   
   Please use `Dn.isDescendantOf` against the user base that matches the 
DN-mapping mode, or check the member entry's objectClass. The current tests 
don't catch this because `LdapProxyBackendTest` sets `baseDn == remoteBaseDn` 
and the visitor test stubs `isUser`.





Issue Time Tracking
-------------------

    Worklog Id:     (was: 1047847)
    Time Spent: 50m  (was: 40m)

> LDAP Proxy find all members of a role
> -------------------------------------
>
>                 Key: KNOX-3487
>                 URL: https://issues.apache.org/jira/browse/KNOX-3487
>             Project: Apache Knox
>          Issue Type: Improvement
>          Components: Server
>    Affects Versions: 3.1.0
>            Reporter: David Han
>            Assignee: David Han
>            Priority: Major
>             Fix For: 3.1.0
>
>          Time Spent: 50m
>  Remaining Estimate: 0h
>
> When role lookup is enabled on the LDAP Proxy, the LDAP Proxy should be able 
> to return all users that are assigned a role. E.g., 
> {code}
>  ldapsearch  -b 'ou=users,DC=proxy,DC=com' -s sub 
> '(memberOf=cn=rolename,ou=groups,dc=proxy,dc=com)' '*'
> {code}
> This would require calling the roles lookup service to get the mapped users 
> and groups for the given role. Then modifying the search filter to replace 
> the rolename with the users and groups.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to