Make LDAP searchForUsersStream
consistent with other storages
Co-authored-by: mhajas <mhajas@redhat.com> Closes #17294
This commit is contained in:
parent
b6a4b0f803
commit
fd6a6ec3ad
13 changed files with 431 additions and 128 deletions
|
@ -195,3 +195,8 @@ Up to this version, the resolving of fallback messages was inconsistent across t
|
|||
The implementation has now been unified for all themes. In general, the message for the most specific matching language tag has the highest priority. If there are both a realm localization message and a Theme 18n message, the realm localization message has the higher priority. Summarized, the priority of the messages is as follows (RL = realm localization, T = Theme i18n files): `RL <variant> > T <variant> > RL <region> > T <region> > RL <language> > T <language> > RL en > T en`.
|
||||
|
||||
Probably this can be better explained with an example: When the variant `de-CH-1996` is requested and there is a realm localization message for the variant, this message will be used. If such a realm localization message does not exist, the Theme i18n files are searched for a corresponding message for that variant. If such a message does not exist, a realm localization message for the region (`de-CH`) will be searched. If such a realm localization message does not exist, the Theme i18n files are searched for a message for that region. If still no message is found, a realm localization message for the language (`de`) will be searched. If there is no matching realm localization message, the Theme i18n files are be searched for a message for that language. As last fallback, the English (`en`) translation is used: First, an English realm localization will be searched - if not found, the Theme 18n files are searched for an English message.
|
||||
|
||||
= LDAPStorageProvider search changes
|
||||
|
||||
Starting with this release Keycloak uses a pagination mechanism when querying federated LDAP database.
|
||||
Searching for users should be consistent with search in local database.
|
||||
|
|
|
@ -29,6 +29,12 @@
|
|||
<name>Keycloak LDAP UserStoreProvider</name>
|
||||
<description />
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>11</maven.compiler.release>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
|
|
|
@ -18,8 +18,10 @@
|
|||
package org.keycloak.storage.ldap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
@ -28,6 +30,7 @@ import java.util.function.Predicate;
|
|||
import java.util.stream.Stream;
|
||||
|
||||
import javax.naming.AuthenticationException;
|
||||
import javax.naming.NamingException;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.constants.KerberosConstants;
|
||||
|
@ -343,53 +346,59 @@ public class LDAPStorageProvider implements UserStorageProvider,
|
|||
|
||||
@Override
|
||||
public int getUsersCount(RealmModel realm) {
|
||||
return 0;
|
||||
return getUsersCount(realm, Collections.emptyMap());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<UserModel> getUsersStream(RealmModel realm) {
|
||||
return Stream.empty();
|
||||
public int getUsersCount(RealmModel realm, String search) {
|
||||
return (int) searchLDAP(realm, search, null, null).filter(filterLocalUsers(realm)).count();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<UserModel> getUsersStream(RealmModel realm, Integer firstResult, Integer maxResults) {
|
||||
return Stream.empty();
|
||||
public int getUsersCount(RealmModel realm, Map<String, String> params) {
|
||||
return (int) searchLDAPByAttributes(realm, params, null, null).filter(filterLocalUsers(realm)).count();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getUsersCount(RealmModel realm, Set<String> groupIds) {
|
||||
throw new UnsupportedOperationException("Not implemented");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getUsersCount(RealmModel realm, String search, Set<String> groupIds) {
|
||||
throw new UnsupportedOperationException("Not implemented");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getUsersCount(RealmModel realm, Map<String, String> params, Set<String> groupIds) {
|
||||
throw new UnsupportedOperationException("Not implemented");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
|
||||
Map<String, String> attributes = new HashMap<String, String>();
|
||||
attributes.put(UserModel.SEARCH,search);
|
||||
return searchForUserStream(realm, attributes, firstResult, maxResults);
|
||||
return searchForUserStream(realm, Map.of(UserModel.SEARCH, search), firstResult, maxResults);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* It supports
|
||||
* <ul>
|
||||
* <li>{@link UserModel#FIRST_NAME}</li>
|
||||
* <li>{@link UserModel#LAST_NAME}</li>
|
||||
* <li>{@link UserModel#EMAIL}</li>
|
||||
* <li>{@link UserModel#USERNAME}</li>
|
||||
* </ul>
|
||||
*
|
||||
* Other fields are not supported. The search for LDAP REST endpoints is done in the context of fields which are stored in LDAP (above).
|
||||
*/
|
||||
@Override
|
||||
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults) {
|
||||
String search = params.get(UserModel.SEARCH);
|
||||
if(search!=null) {
|
||||
int spaceIndex = search.lastIndexOf(' ');
|
||||
if (spaceIndex > -1) {
|
||||
String firstName = search.substring(0, spaceIndex).trim();
|
||||
String lastName = search.substring(spaceIndex).trim();
|
||||
params.put(UserModel.FIRST_NAME, firstName);
|
||||
params.put(UserModel.LAST_NAME, lastName);
|
||||
} else if (search.indexOf('@') > -1) {
|
||||
params.put(UserModel.USERNAME, search.trim().toLowerCase());
|
||||
params.put(UserModel.EMAIL, search.trim().toLowerCase());
|
||||
} else {
|
||||
params.put(UserModel.LAST_NAME, search.trim());
|
||||
params.put(UserModel.USERNAME, search.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
Stream<LDAPObject> result = search != null ?
|
||||
searchLDAP(realm, search, firstResult, maxResults) :
|
||||
searchLDAPByAttributes(realm, params, firstResult, maxResults);
|
||||
|
||||
Stream<LDAPObject> stream = searchLDAP(realm, params).stream()
|
||||
.filter(ldapObject -> {
|
||||
String ldapUsername = LDAPUtils.getUsername(ldapObject, this.ldapIdentityStore.getConfig());
|
||||
return (UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, ldapUsername) == null);
|
||||
});
|
||||
|
||||
return paginatedStream(stream, firstResult, maxResults).map(ldapObject -> importUserFromLDAP(session, realm, ldapObject));
|
||||
return paginatedStream(result.filter(filterLocalUsers(realm)), firstResult, maxResults)
|
||||
.map(ldapObject -> importUserFromLDAP(session, realm, ldapObject));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -434,71 +443,72 @@ public class LDAPStorageProvider implements UserStorageProvider,
|
|||
return result;
|
||||
}
|
||||
|
||||
protected List<LDAPObject> searchLDAP(RealmModel realm, Map<String, String> attributes) {
|
||||
/**
|
||||
* Searches LDAP using logical conjunction of params. It supports
|
||||
* <ul>
|
||||
* <li>{@link UserModel#FIRST_NAME}</li>
|
||||
* <li>{@link UserModel#LAST_NAME}</li>
|
||||
* <li>{@link UserModel#EMAIL}</li>
|
||||
* <li>{@link UserModel#USERNAME}</li>
|
||||
* </ul>
|
||||
*
|
||||
* For zero or any other param it returns all users.
|
||||
*/
|
||||
private Stream<LDAPObject> searchLDAPByAttributes(RealmModel realm, Map<String, String> attributes, Integer firstResult, Integer maxResults) {
|
||||
|
||||
// return a stable ordered result to the caller
|
||||
List<LDAPObject> results = new ArrayList<>();
|
||||
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
|
||||
|
||||
// a set to ensure fast uniqueness checks based on equals/hashCode of LDAPObject
|
||||
Set<LDAPObject> unique = new HashSet<>();
|
||||
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
|
||||
|
||||
if (attributes.containsKey(UserModel.USERNAME)) {
|
||||
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
|
||||
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
|
||||
|
||||
// Mapper should replace "username" in parameter name with correct LDAP mapped attribute
|
||||
Condition usernameCondition = conditionsBuilder.equal(UserModel.USERNAME, attributes.get(UserModel.USERNAME), EscapeStrategy.NON_ASCII_CHARS_ONLY);
|
||||
ldapQuery.addWhereCondition(usernameCondition);
|
||||
|
||||
List<LDAPObject> ldapObjects = ldapQuery.getResultList();
|
||||
results.addAll(ldapObjects);
|
||||
unique.addAll(ldapObjects);
|
||||
// Mapper should replace parameter with correct LDAP mapped attributes
|
||||
if (attributes.containsKey(UserModel.USERNAME)) {
|
||||
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.USERNAME, attributes.get(UserModel.USERNAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
}
|
||||
}
|
||||
|
||||
if (attributes.containsKey(UserModel.EMAIL)) {
|
||||
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
|
||||
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
|
||||
|
||||
// Mapper should replace "email" in parameter name with correct LDAP mapped attribute
|
||||
Condition emailCondition = conditionsBuilder.equal(UserModel.EMAIL, attributes.get(UserModel.EMAIL), EscapeStrategy.NON_ASCII_CHARS_ONLY);
|
||||
ldapQuery.addWhereCondition(emailCondition);
|
||||
|
||||
List<LDAPObject> ldapObjects = ldapQuery.getResultList();
|
||||
ldapObjects.forEach(ldapObject -> {
|
||||
// ensure that no entity is listed twice and still preserve the order of returned entities
|
||||
if (!unique.contains(ldapObject)) {
|
||||
results.add(ldapObject);
|
||||
unique.add(ldapObject);
|
||||
}
|
||||
});
|
||||
if (attributes.containsKey(UserModel.EMAIL)) {
|
||||
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.EMAIL, attributes.get(UserModel.EMAIL), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
}
|
||||
}
|
||||
|
||||
if (attributes.containsKey(UserModel.FIRST_NAME) || attributes.containsKey(UserModel.LAST_NAME)) {
|
||||
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
|
||||
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
|
||||
|
||||
// Mapper should replace parameter with correct LDAP mapped attributes
|
||||
if (attributes.containsKey(UserModel.FIRST_NAME)) {
|
||||
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.FIRST_NAME, attributes.get(UserModel.FIRST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
}
|
||||
if (attributes.containsKey(UserModel.LAST_NAME)) {
|
||||
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.LAST_NAME, attributes.get(UserModel.LAST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
}
|
||||
|
||||
List<LDAPObject> ldapObjects = ldapQuery.getResultList();
|
||||
ldapObjects.forEach(ldapObject -> {
|
||||
// ensure that no entity is listed twice and still preserve the order of returned entities
|
||||
if (!unique.contains(ldapObject)) {
|
||||
results.add(ldapObject);
|
||||
unique.add(ldapObject);
|
||||
}
|
||||
});
|
||||
if (attributes.containsKey(UserModel.FIRST_NAME)) {
|
||||
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.FIRST_NAME, attributes.get(UserModel.FIRST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
}
|
||||
if (attributes.containsKey(UserModel.LAST_NAME)) {
|
||||
ldapQuery.addWhereCondition(conditionsBuilder.equal(UserModel.LAST_NAME, attributes.get(UserModel.LAST_NAME), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
}
|
||||
// for all other searchable fields: Ignoring is the fallback option, since it may overestimate the results but does not ignore matches.
|
||||
// for empty params: all users are returned (pagination applies)
|
||||
return paginatedSearchLDAP(ldapQuery, firstResult, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
/**
|
||||
* Searches LDAP using logical disjunction of params. It supports
|
||||
* <ul>
|
||||
* <li>{@link UserModel#FIRST_NAME}</li>
|
||||
* <li>{@link UserModel#LAST_NAME}</li>
|
||||
* <li>{@link UserModel#EMAIL}</li>
|
||||
* <li>{@link UserModel#USERNAME}</li>
|
||||
* </ul>
|
||||
*
|
||||
* It uses multiple LDAP calls and results are combined together with respect to firstResult and maxResults
|
||||
*
|
||||
* This method serves for {@code search} param of {@link org.keycloak.services.resources.admin.UsersResource#getUsers}
|
||||
*/
|
||||
private Stream<LDAPObject> searchLDAP(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
|
||||
|
||||
try (LDAPQuery ldapQuery = LDAPUtils.createQueryForUserSearch(this, realm)) {
|
||||
LDAPQueryConditionsBuilder conditionsBuilder = new LDAPQueryConditionsBuilder();
|
||||
|
||||
List<Condition> conditions = new LinkedList<>();
|
||||
for (String s : search.split("\\s+")) {
|
||||
conditions.add(conditionsBuilder.equal(UserModel.USERNAME, s.trim().toLowerCase(), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
conditions.add(conditionsBuilder.equal(UserModel.EMAIL, s.trim().toLowerCase(), EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
conditions.add(conditionsBuilder.equal(UserModel.FIRST_NAME, s, EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
conditions.add(conditionsBuilder.equal(UserModel.LAST_NAME, s, EscapeStrategy.NON_ASCII_CHARS_ONLY));
|
||||
}
|
||||
|
||||
ldapQuery.addWhereCondition(conditionsBuilder.orCondition(conditions.toArray(Condition[]::new)));
|
||||
|
||||
return paginatedSearchLDAP(ldapQuery, firstResult, maxResults);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -542,7 +552,7 @@ public class LDAPStorageProvider implements UserStorageProvider,
|
|||
String ldapUsername = LDAPUtils.getUsername(ldapUser, ldapIdentityStore.getConfig());
|
||||
LDAPUtils.checkUuid(ldapUser, ldapIdentityStore.getConfig());
|
||||
|
||||
UserModel imported = null;
|
||||
UserModel imported;
|
||||
if (model.isImportEnabled()) {
|
||||
// Search if there is already an existing user, which means the username might have changed in LDAP without Keycloak knowing about it
|
||||
UserModel existingLocalUser = UserStoragePrivateUtil.userLocalStorage(session)
|
||||
|
@ -717,7 +727,7 @@ public class LDAPStorageProvider implements UserStorageProvider,
|
|||
}
|
||||
|
||||
public Set<String> getSupportedCredentialTypes() {
|
||||
return new HashSet<String>(this.supportedCredentialTypes);
|
||||
return new HashSet<>(this.supportedCredentialTypes);
|
||||
}
|
||||
|
||||
|
||||
|
@ -752,7 +762,7 @@ public class LDAPStorageProvider implements UserStorageProvider,
|
|||
|
||||
spnegoAuthenticator.authenticate();
|
||||
|
||||
Map<String, String> state = new HashMap<String, String>();
|
||||
Map<String, String> state = new HashMap<>();
|
||||
if (spnegoAuthenticator.isAuthenticated()) {
|
||||
|
||||
// TODO: This assumes that LDAP "uid" is equal to kerberos principal name. Like uid "hnelson" and kerberos principal "hnelson@KEYCLOAK.ORG".
|
||||
|
@ -858,4 +868,63 @@ public class LDAPStorageProvider implements UserStorageProvider,
|
|||
}
|
||||
}
|
||||
|
||||
private Predicate<LDAPObject> filterLocalUsers(RealmModel realm) {
|
||||
return ldapObject -> UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, LDAPUtils.getUsername(ldapObject, LDAPStorageProvider.this.ldapIdentityStore.getConfig())) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method leverages existing pagination support in {@link LDAPQuery#getResultList()}. It sets the limit for the query
|
||||
* based on {@code firstResult}, {@code maxResults} and {@link LDAPConfig#getBatchSizeForSync()}.
|
||||
*
|
||||
* <p/>
|
||||
* Internally it uses {@link Stream#iterate(java.lang.Object, java.util.function.Predicate, java.util.function.UnaryOperator)}
|
||||
* to ensure there will be obtained required number of users considering a fact that some of the returned ldap users could be
|
||||
* filtered out (as they might be already imported in local storage). The returned {@code Stream<LDAPObject>} will be filled
|
||||
* "on demand".
|
||||
*/
|
||||
private Stream<LDAPObject> paginatedSearchLDAP(LDAPQuery ldapQuery, Integer firstResult, Integer maxResults) {
|
||||
LDAPConfig ldapConfig = ldapQuery.getLdapProvider().getLdapIdentityStore().getConfig();
|
||||
|
||||
if (ldapConfig.isPagination()) {
|
||||
|
||||
final int limit;
|
||||
if (maxResults != null && maxResults >= 0) {
|
||||
if (firstResult != null && firstResult > 0) {
|
||||
limit = Integer.min(ldapConfig.getBatchSizeForSync(), Integer.sum(firstResult, maxResults));
|
||||
} else {
|
||||
limit = Integer.min(ldapConfig.getBatchSizeForSync(), maxResults);
|
||||
}
|
||||
} else {
|
||||
if (firstResult != null && firstResult > 0) {
|
||||
limit = Integer.min(ldapConfig.getBatchSizeForSync(), firstResult);
|
||||
} else {
|
||||
limit = ldapConfig.getBatchSizeForSync();
|
||||
}
|
||||
}
|
||||
|
||||
return Stream.iterate(ldapQuery,
|
||||
query -> {
|
||||
//the very 1st page - Pagination context might not yet be present
|
||||
if (query.getPaginationContext() == null) try {
|
||||
query.initPagination();
|
||||
//returning true for first iteration as the LDAP was not queried yet
|
||||
return true;
|
||||
} catch (NamingException e) {
|
||||
throw new ModelException("Querying of LDAP failed " + query, e);
|
||||
}
|
||||
return query.getPaginationContext().hasNextPage();
|
||||
},
|
||||
query -> query
|
||||
).flatMap(query -> {
|
||||
query.setLimit(limit);
|
||||
List<LDAPObject> ldapObjects = query.getResultList();
|
||||
if (ldapObjects.isEmpty()) {
|
||||
return Stream.empty();
|
||||
}
|
||||
return ldapObjects.stream();
|
||||
});
|
||||
}
|
||||
|
||||
return ldapQuery.getResultList().stream();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,20 +51,19 @@ public class LDAPQuery implements AutoCloseable {
|
|||
|
||||
private final LDAPStorageProvider ldapFedProvider;
|
||||
|
||||
private int offset;
|
||||
private int limit;
|
||||
private PaginationContext paginationContext;
|
||||
private LDAPContextManager ldapContextManager;
|
||||
private String searchDn;
|
||||
private final Set<Condition> conditions = new LinkedHashSet<Condition>();
|
||||
private final Set<Sort> ordering = new LinkedHashSet<Sort>();
|
||||
private final Set<Condition> conditions = new LinkedHashSet<>();
|
||||
private final Set<Sort> ordering = new LinkedHashSet<>();
|
||||
|
||||
private final Set<String> returningLdapAttributes = new LinkedHashSet<String>();
|
||||
private final Set<String> returningLdapAttributes = new LinkedHashSet<>();
|
||||
|
||||
// Contains just those returningLdapAttributes, which are read-only. They will be marked as read-only in returned LDAPObject instances as well
|
||||
// NOTE: names of attributes are lower-cased to avoid case sensitivity issues (LDAP searching is usually case-insensitive, so we want to be as well)
|
||||
private final Set<String> returningReadOnlyLdapAttributes = new LinkedHashSet<String>();
|
||||
private final Set<String> objectClasses = new LinkedHashSet<String>();
|
||||
private final Set<String> returningReadOnlyLdapAttributes = new LinkedHashSet<>();
|
||||
private final Set<String> objectClasses = new LinkedHashSet<>();
|
||||
|
||||
private final List<ComponentModel> mappers = new ArrayList<>();
|
||||
|
||||
|
@ -146,10 +145,6 @@ public class LDAPQuery implements AutoCloseable {
|
|||
return limit;
|
||||
}
|
||||
|
||||
public int getOffset() {
|
||||
return offset;
|
||||
}
|
||||
|
||||
public PaginationContext getPaginationContext() {
|
||||
return paginationContext;
|
||||
}
|
||||
|
@ -166,7 +161,7 @@ public class LDAPQuery implements AutoCloseable {
|
|||
fedMapper.beforeLDAPQuery(this);
|
||||
}
|
||||
|
||||
List<LDAPObject> result = new ArrayList<LDAPObject>();
|
||||
List<LDAPObject> result = new ArrayList<>();
|
||||
|
||||
try {
|
||||
for (LDAPObject ldapObject : ldapFedProvider.getLdapIdentityStore().fetchQueryResults(this)) {
|
||||
|
@ -195,11 +190,6 @@ public class LDAPQuery implements AutoCloseable {
|
|||
return ldapFedProvider.getLdapIdentityStore().countQueryResults(this);
|
||||
}
|
||||
|
||||
public LDAPQuery setOffset(int offset) {
|
||||
this.offset = offset;
|
||||
return this;
|
||||
}
|
||||
|
||||
public LDAPQuery setLimit(int limit) {
|
||||
this.limit = limit;
|
||||
return this;
|
||||
|
|
|
@ -71,9 +71,9 @@ public final class LDAPContextManager implements AutoCloseable {
|
|||
if (!LDAPConstants.AUTH_TYPE_NONE.equals(ldapConfig.getAuthType())) {
|
||||
vaultCharSecret = getVaultSecret();
|
||||
|
||||
if (vaultCharSecret != null && !ldapConfig.isStartTls()) {
|
||||
if (vaultCharSecret != null && !ldapConfig.isStartTls() && ldapConfig.getBindCredential() != null) {
|
||||
connProp.put(SECURITY_CREDENTIALS, vaultCharSecret.getAsArray()
|
||||
.orElse(ldapConfig.getBindCredential() != null? ldapConfig.getBindCredential().toCharArray() : null));
|
||||
.orElse(ldapConfig.getBindCredential().toCharArray()));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,7 +140,7 @@ public final class LDAPContextManager implements AutoCloseable {
|
|||
if(!ldapConfig.isStartTls()) {
|
||||
String authType = ldapConfig.getAuthType();
|
||||
|
||||
env.put(Context.SECURITY_AUTHENTICATION, authType);
|
||||
if (authType != null) env.put(Context.SECURITY_AUTHENTICATION, authType);
|
||||
|
||||
String bindDN = ldapConfig.getBindDN();
|
||||
|
||||
|
@ -151,8 +151,8 @@ public final class LDAPContextManager implements AutoCloseable {
|
|||
}
|
||||
|
||||
if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) {
|
||||
env.put(Context.SECURITY_PRINCIPAL, bindDN);
|
||||
env.put(Context.SECURITY_CREDENTIALS, bindCredential);
|
||||
if (bindDN != null) env.put(Context.SECURITY_PRINCIPAL, bindDN);
|
||||
if (bindCredential != null) env.put(Context.SECURITY_CREDENTIALS, bindCredential);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -295,15 +295,12 @@ public class LDAPIdentityStore implements IdentityStore {
|
|||
@Override
|
||||
public int countQueryResults(LDAPQuery identityQuery) {
|
||||
int limit = identityQuery.getLimit();
|
||||
int offset = identityQuery.getOffset();
|
||||
|
||||
identityQuery.setLimit(0);
|
||||
identityQuery.setOffset(0);
|
||||
|
||||
int resultCount = identityQuery.getResultList().size();
|
||||
|
||||
identityQuery.setLimit(limit);
|
||||
identityQuery.setOffset(offset);
|
||||
|
||||
return resultCount;
|
||||
}
|
||||
|
|
|
@ -103,13 +103,13 @@ public class LDAPOperationManager {
|
|||
*/
|
||||
public void modifyAttributes(String dn, NamingEnumeration<Attribute> attributes) {
|
||||
try {
|
||||
List<ModificationItem> modItems = new ArrayList<ModificationItem>();
|
||||
List<ModificationItem> modItems = new ArrayList<>();
|
||||
while (attributes.hasMore()) {
|
||||
ModificationItem modItem = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attributes.next());
|
||||
modItems.add(modItem);
|
||||
}
|
||||
|
||||
modifyAttributes(dn, modItems.toArray(new ModificationItem[] {}), null);
|
||||
modifyAttributes(dn, modItems.toArray(ModificationItem[]::new), null);
|
||||
} catch (NamingException ne) {
|
||||
throw new ModelException("Could not modify attributes on entry from DN [" + dn + "]", ne);
|
||||
}
|
||||
|
@ -246,7 +246,7 @@ public class LDAPOperationManager {
|
|||
|
||||
|
||||
public List<SearchResult> search(final String baseDN, final String filter, Collection<String> returningAttributes, int searchScope) throws NamingException {
|
||||
final List<SearchResult> result = new ArrayList<SearchResult>();
|
||||
final List<SearchResult> result = new ArrayList<>();
|
||||
final SearchControls cons = getSearchControls(returningAttributes, searchScope);
|
||||
|
||||
try {
|
||||
|
@ -285,7 +285,7 @@ public class LDAPOperationManager {
|
|||
}
|
||||
|
||||
public List<SearchResult> searchPaginated(final String baseDN, final String filter, final LDAPQuery identityQuery) throws NamingException {
|
||||
final List<SearchResult> result = new ArrayList<SearchResult>();
|
||||
final List<SearchResult> result = new ArrayList<>();
|
||||
final SearchControls cons = getSearchControls(identityQuery.getReturningLdapAttributes(), identityQuery.getSearchScope());
|
||||
|
||||
// Very 1st page. Pagination context is not yet present
|
||||
|
|
|
@ -62,7 +62,6 @@ import org.keycloak.storage.ldap.mappers.LDAPStorageMapper;
|
|||
import org.keycloak.testsuite.ProfileAssume;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.client.KeycloakTestingClient;
|
||||
import org.keycloak.testsuite.util.RealmRepUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
|
@ -78,6 +77,7 @@ import java.util.function.Predicate;
|
|||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hamcrest.Matcher;
|
||||
import org.hamcrest.MatcherAssert;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
|
@ -107,7 +107,6 @@ public class ExportImportUtil {
|
|||
|
||||
RealmResource realmRsc = adminClient.realm(realm.getRealm());
|
||||
|
||||
/* See KEYCLOAK-3104*/
|
||||
UserRepresentation user = findByUsername(realmRsc, "loginclient");
|
||||
Assert.assertNotNull(user);
|
||||
|
||||
|
@ -478,12 +477,10 @@ public class ExportImportUtil {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Workaround for KEYCLOAK-3104. For this realm, search() only works if username is null.
|
||||
private static UserRepresentation findByUsername(RealmResource realmRsc, String username) {
|
||||
for (UserRepresentation user : realmRsc.users().search(null, 0, -1)) {
|
||||
if (user.getUsername().equalsIgnoreCase(username)) return user;
|
||||
}
|
||||
return null;
|
||||
List<UserRepresentation> usersByUsername = realmRsc.users().search(username);
|
||||
MatcherAssert.assertThat(usersByUsername, Matchers.hasSize(1));
|
||||
return usersByUsername.get(0);
|
||||
}
|
||||
|
||||
private static Set<RoleRepresentation> allScopeMappings(ClientResource client) {
|
||||
|
|
|
@ -43,8 +43,6 @@ import org.junit.BeforeClass;
|
|||
*/
|
||||
public abstract class AbstractLDAPTest extends AbstractTestRealmKeycloakTest {
|
||||
|
||||
static final String TEST_REALM_NAME = "test";
|
||||
|
||||
protected static String ldapModelId;
|
||||
|
||||
@Rule
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright 2023 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed 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.keycloak.testsuite.federation.ldap;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.hasItems;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.FixMethodOrder;
|
||||
import org.junit.Test;
|
||||
import org.junit.runners.MethodSorters;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.util.LDAPRule;
|
||||
import org.keycloak.testsuite.util.LDAPTestUtils;
|
||||
|
||||
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
|
||||
public class LDAPSearchForUsersPaginationTest extends AbstractLDAPTest {
|
||||
|
||||
@ClassRule
|
||||
public static LDAPRule ldapRule = new LDAPRule();
|
||||
|
||||
@Override
|
||||
protected LDAPRule getLDAPRule() {
|
||||
return ldapRule;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterImportTestRealm() {
|
||||
testingClient.server().run(session -> {
|
||||
|
||||
LDAPTestContext ctx = LDAPTestContext.init(session);
|
||||
RealmModel appRealm = ctx.getRealm();
|
||||
|
||||
// Delete all local users and add some new for testing
|
||||
session.users().searchForUserStream(appRealm, new HashMap<>()).collect(Collectors.toList()).forEach(u -> session.users().removeUser(appRealm, u));
|
||||
|
||||
// Delete all LDAP users and add some new for testing
|
||||
LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm);
|
||||
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john", "Some", "Some", "john14@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john00", "john", "Doe", "john0@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john01", "john", "Doe", "john1@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john02", "john", "Doe", "john2@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john03", "john", "Doe", "john3@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john04", "john", "Doe", "john4@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john05", "Some", "john", "john5@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john06", "Some", "john", "john6@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john07", "Some", "john", "john7@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john08", "Some", "john", "john8@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john09", "Some", "john", "john9@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john10", "Some", "Some", "john10@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john11", "Some", "Some", "john11@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john12", "Some", "Some", "john12@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john13", "Some", "Some", "john13@email.org", null, "1234");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPagination() {
|
||||
//this call should import some users into local database
|
||||
//collecting to TreeSet for ordering as users are orderd by username when querying from local database
|
||||
@SuppressWarnings("unchecked")
|
||||
LinkedList<String> importedUsers = new LinkedList(adminClient.realm(TEST_REALM_NAME).users().search("*", 0, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toCollection(TreeSet::new)));
|
||||
|
||||
//this call should ommit first 3 already imported users from local db
|
||||
//it should return 2 local(imported) users and 8 users from ldap
|
||||
List<String> search = adminClient.realm(TEST_REALM_NAME).users().search("*", 3, 10).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
|
||||
|
||||
assertThat(search, hasSize(10));
|
||||
assertThat(search, not(contains(importedUsers.get(0))));
|
||||
assertThat(search, not(contains(importedUsers.get(1))));
|
||||
assertThat(search, not(contains(importedUsers.get(2))));
|
||||
assertThat(search, hasItems(importedUsers.get(3), importedUsers.get(4)));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* Copyright 2023 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed 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.keycloak.testsuite.federation.ldap.noimport;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.FixMethodOrder;
|
||||
import org.junit.Test;
|
||||
import org.junit.runners.MethodSorters;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.federation.ldap.AbstractLDAPTest;
|
||||
import org.keycloak.testsuite.federation.ldap.LDAPTestContext;
|
||||
import org.keycloak.testsuite.util.LDAPRule;
|
||||
import org.keycloak.testsuite.util.LDAPTestUtils;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||
import static org.hamcrest.Matchers.hasItem;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
|
||||
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
|
||||
public class LDAPSearchForUsersPaginationNoImportTest extends AbstractLDAPTest {
|
||||
|
||||
@ClassRule
|
||||
public static LDAPRule ldapRule = new LDAPRule();
|
||||
|
||||
@Override
|
||||
protected LDAPRule getLDAPRule() {
|
||||
return ldapRule;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isImportEnabled() {
|
||||
// always load users from ldap directly
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterImportTestRealm() {
|
||||
testingClient.server().run(session -> {
|
||||
|
||||
LDAPTestContext ctx = LDAPTestContext.init(session);
|
||||
RealmModel appRealm = ctx.getRealm();
|
||||
|
||||
// Delete all local users to not interfere with federated ones
|
||||
session.users().searchForUserStream(appRealm, new HashMap<>()).collect(Collectors.toList()).forEach(u -> session.users().removeUser(appRealm, u));
|
||||
|
||||
// Delete all LDAP users and add some new for testing
|
||||
LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm);
|
||||
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john", "Some", "Some", "john14@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john00", "john", "Doe", "john0@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john01", "john", "Doe", "john1@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john02", "john", "Doe", "john2@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john03", "john", "Doe", "john3@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john04", "john", "Doe", "john4@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john05", "Some", "john", "john5@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john06", "Some", "john", "john6@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john07", "Some", "john", "john7@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john08", "Some", "john", "john8@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john09", "Some", "john", "john9@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john10", "Some", "Some", "john10@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john11", "Some", "Some", "john11@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john12", "Some", "Some", "john12@email.org", null, "1234");
|
||||
LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "john13", "Some", "Some", "john13@email.org", null, "1234");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPagination() {
|
||||
//tests LDAPStorageProvider.searchLDAP(...
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John Some Doe", 0, 15), hasSize(15));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John Some Doe", 7, 10), hasSize(8));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", null, null), hasSize(15));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", null, null), hasSize(15));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 10, 8), hasSize(5));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 0, 10), hasSize(10));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 7, 10), hasSize(8));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 15, 100), hasSize(0));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("*", 14, 2), hasSize(1));
|
||||
|
||||
//tests LDAPStorageProvider.searchLDAP(...
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", null, null), hasSize(11));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 10, 8), hasSize(1));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 0, 10), hasSize(10));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 0, 5), hasSize(5));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 2, 10), hasSize(9));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("John", 0, 8), hasSize(8));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("Some", 0, 20), hasSize(10));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search("Some", 10, 20), hasSize(0));
|
||||
|
||||
//tests LDAPStorageProvider.searchLDAPByAttributes(...
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(), hasSize(15));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(10, 8), hasSize(5));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(0, 10), hasSize(10));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(7, 10), hasSize(8));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(15, 100), hasSize(0));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().list(14, 2), hasSize(1));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search(null, "John", null, null, 0, 15), hasSize(5));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search(null, "Some", "John", null, 0, 15), hasSize(5));
|
||||
assertThat(adminClient.realm(TEST_REALM_NAME).users().search(null, "Some", "John", null, 2, 15), hasSize(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReturnedOrder() {
|
||||
List<String> firstFive = adminClient.realm(TEST_REALM_NAME).users().search("*", 0, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
|
||||
List<String> secondFive = adminClient.realm(TEST_REALM_NAME).users().search("*", 5, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
|
||||
List<String> thirdFive = adminClient.realm(TEST_REALM_NAME).users().search("*", 10, 5).stream().map(UserRepresentation::getUsername).collect(Collectors.toList());
|
||||
|
||||
firstFive.forEach(username -> assertThat(secondFive, not(hasItem(username))));
|
||||
firstFive.forEach(username -> assertThat(thirdFive, not(hasItem(username))));
|
||||
|
||||
secondFive.forEach(username -> assertThat(firstFive, not(hasItem(username))));
|
||||
secondFive.forEach(username -> assertThat(thirdFive, not(hasItem(username))));
|
||||
|
||||
thirdFive.forEach(username -> assertThat(firstFive, not(hasItem(username))));
|
||||
thirdFive.forEach(username -> assertThat(secondFive, not(hasItem(username))));
|
||||
}
|
||||
}
|
|
@ -3517,6 +3517,7 @@
|
|||
"displayName" : "ldap-provider",
|
||||
"providerName" : "ldap",
|
||||
"config" : {
|
||||
"enabled" : "false",
|
||||
"serverPrincipal" : "principal",
|
||||
"debug" : "true",
|
||||
"pagination" : "true",
|
||||
|
|
|
@ -56,6 +56,7 @@
|
|||
"providerName": "ldap",
|
||||
"priority": 1,
|
||||
"config": {
|
||||
"enabled": false,
|
||||
"connectionUrl": "ldap://foo",
|
||||
"editMode": "WRITABLE"
|
||||
}
|
||||
|
@ -65,6 +66,7 @@
|
|||
"providerName": "ldap",
|
||||
"priority": 2,
|
||||
"config": {
|
||||
"enabled": false,
|
||||
"connectionUrl": "ldap://bar",
|
||||
"editMode": "WRITABLE"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue