@@ -184,6 +184,11 @@ public class UserDao implements Dao { | |||
return mapper(dbSession).selectByExternalLoginAndIdentityProvider(externalLogin, externalIdentityProvider); | |||
} | |||
public List<UserDto> selectByExternalLogin(DbSession dbSession, String externalLogin) { | |||
return mapper(dbSession).selectByExternalLogin(externalLogin); | |||
} | |||
public long countSonarlintWeeklyUsers(DbSession dbSession) { | |||
long threshold = system2.now() - WEEK_IN_MS; | |||
return mapper(dbSession).countActiveSonarlintUsers(threshold); |
@@ -64,6 +64,8 @@ public interface UserMapper { | |||
@CheckForNull | |||
UserDto selectByExternalLoginAndIdentityProvider(@Param("externalLogin") String externalLogin, @Param("externalIdentityProvider") String externalExternalIdentityProvider); | |||
List<UserDto> selectByExternalLogin(@Param("externalLogin") String externalLogin); | |||
List<String> selectExternalIdentityProviders(); | |||
void scrollAll(ResultHandler<UserDto> handler); |
@@ -152,6 +152,13 @@ | |||
WHERE u.external_login=#{externalLogin, jdbcType=VARCHAR} AND u.external_identity_provider=#{externalIdentityProvider, jdbcType=VARCHAR} | |||
</select> | |||
<select id="selectByExternalLogin" parameterType="map" resultType="User"> | |||
SELECT | |||
<include refid="userColumns"/> | |||
FROM users u | |||
WHERE u.external_login=#{externalLogin, jdbcType=VARCHAR} | |||
</select> | |||
<sql id="deactivateUserUpdatedFields"> | |||
active = ${_false}, | |||
email = null, |
@@ -535,6 +535,22 @@ public class UserDaoTest { | |||
assertThat(underTest.selectByExternalLoginAndIdentityProvider(session, "unknown", "unknown")).isNull(); | |||
} | |||
@Test | |||
public void select_by_external_login() { | |||
UserDto activeUser = db.users().insertUser(); | |||
UserDto disableUser = db.users().insertUser(u -> u.setActive(false)); | |||
findByExternalLoginAndAssertResult(activeUser); | |||
findByExternalLoginAndAssertResult(disableUser); | |||
assertThat(underTest.selectByExternalLogin(session, "unknown")).isEmpty(); | |||
} | |||
private void findByExternalLoginAndAssertResult(UserDto activeUser) { | |||
assertThat(underTest.selectByExternalLogin(session, activeUser.getExternalLogin())) | |||
.extracting(UserDto::getLogin) | |||
.containsExactly(activeUser.getLogin()); | |||
} | |||
@Test | |||
public void scrollByLUuids() { | |||
UserDto u1 = insertUser(true); |
@@ -24,6 +24,8 @@ import java.util.Collection; | |||
import java.util.List; | |||
import java.util.Map; | |||
import java.util.Objects; | |||
import java.util.Set; | |||
import java.util.TreeSet; | |||
import javax.annotation.CheckForNull; | |||
import javax.annotation.Nullable; | |||
import org.sonar.api.server.ws.Change; | |||
@@ -36,6 +38,7 @@ import org.sonar.db.DbSession; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.server.es.SearchOptions; | |||
import org.sonar.server.es.SearchResult; | |||
import org.sonar.server.exceptions.ServerException; | |||
import org.sonar.server.issue.AvatarResolver; | |||
import org.sonar.server.user.UserSession; | |||
import org.sonar.server.user.index.UserDoc; | |||
@@ -48,6 +51,7 @@ import static com.google.common.base.MoreObjects.firstNonNull; | |||
import static com.google.common.base.Preconditions.checkArgument; | |||
import static com.google.common.base.Strings.emptyToNull; | |||
import static java.util.Optional.ofNullable; | |||
import static java.util.stream.Collectors.toSet; | |||
import static org.sonar.api.server.ws.WebService.Param.PAGE; | |||
import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE; | |||
import static org.sonar.api.server.ws.WebService.Param.TEXT_QUERY; | |||
@@ -62,6 +66,7 @@ import static org.sonarqube.ws.Users.SearchWsResponse.newBuilder; | |||
public class SearchAction implements UsersWsAction { | |||
private static final String DEACTIVATED_PARAM = "deactivated"; | |||
static final String EXTERNAL_IDENTITY = "externalIdentity"; | |||
private static final int MAX_PAGE_SIZE = 500; | |||
private final UserSession userSession; | |||
@@ -92,6 +97,7 @@ public class SearchAction implements UsersWsAction { | |||
"Field 'lastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user authenticates many times in less than one hour.") | |||
.setSince("3.6") | |||
.setChangelog( | |||
new Change("9.9.3", "New optional parameters " + EXTERNAL_IDENTITY + " to find a user by its IdP login"), | |||
new Change("9.7", "New parameter 'deactivated' to optionally search for deactivated users"), | |||
new Change("7.7", "New field 'lastConnectionDate' is added to response"), | |||
new Change("7.4", "External identity is only returned to system administrators"), | |||
@@ -123,27 +129,53 @@ public class SearchAction implements UsersWsAction { | |||
.setRequired(false) | |||
.setDefaultValue(false) | |||
.setBooleanPossibleValues(); | |||
action.createParam(EXTERNAL_IDENTITY) | |||
.setSince("9.9.3") | |||
.setDescription(""" | |||
Find a user by its external identity (ie. its login in the Identity Provider). | |||
This is case sensitive and only available with Administer System permission. | |||
"""); | |||
} | |||
@Override | |||
public void handle(Request request, Response response) throws Exception { | |||
throwIfAdminOnlyParametersAreUsed(request); | |||
Users.SearchWsResponse wsResponse = doHandle(toSearchRequest(request)); | |||
writeProtobuf(wsResponse, request, response); | |||
} | |||
private void throwIfAdminOnlyParametersAreUsed(Request request) { | |||
if (!userSession.isSystemAdministrator() && (request.param(EXTERNAL_IDENTITY) != null)) { | |||
throw new ServerException(403, "parameter " + EXTERNAL_IDENTITY + " requires Administer System permission."); | |||
} | |||
} | |||
private Users.SearchWsResponse doHandle(SearchRequest request) { | |||
SearchOptions options = new SearchOptions().setPage(request.getPage(), request.getPageSize()); | |||
SearchResult<UserDoc> result = userIndex.search(UserQuery.builder().setActive(!request.isDeactivated()).setTextQuery(request.getQuery()).build(), options); | |||
try (DbSession dbSession = dbClient.openSession(false)) { | |||
List<String> logins = result.getDocs().stream().map(UserDoc::login).collect(toList()); | |||
Set<String> logins = result.getDocs().stream().map(UserDoc::login).collect(toSet()); | |||
Multimap<String, String> groupsByLogin = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, logins); | |||
List<UserDto> users = dbClient.userDao().selectByOrderedLogins(dbSession, logins); | |||
logins = findUsersWithExternalLoginsIfDefined(dbSession, request.externalLogin, logins); | |||
List<UserDto> users = dbClient.userDao().selectByOrderedLogins(dbSession, new TreeSet<>(logins)); | |||
Map<String, Integer> tokenCountsByLogin = dbClient.userTokenDao().countTokensByUsers(dbSession, users); | |||
Paging paging = forPageIndex(request.getPage()).withPageSize(request.getPageSize()).andTotal((int) result.getTotal()); | |||
return buildResponse(users, groupsByLogin, tokenCountsByLogin, paging); | |||
} | |||
} | |||
private Set<String> findUsersWithExternalLoginsIfDefined(DbSession dbSession, @Nullable String externalLogin, Set<String> loginsFromSearch) { | |||
if (externalLogin != null) { | |||
List<UserDto> userDtos = dbClient.userDao().selectByExternalLogin(dbSession, externalLogin); | |||
return userDtos.stream().map(UserDto::getLogin) | |||
.filter(loginsFromSearch::contains) | |||
.collect(toSet()); | |||
} | |||
return loginsFromSearch; | |||
} | |||
private SearchWsResponse buildResponse(List<UserDto> users, Multimap<String, String> groupsByLogin, Map<String, Integer> tokenCountsByLogin, Paging paging) { | |||
SearchWsResponse.Builder responseBuilder = newBuilder(); | |||
users.forEach(user -> responseBuilder.addUsers(towsUser(user, firstNonNull(tokenCountsByLogin.get(user.getUuid()), 0), groupsByLogin.get(user.getLogin())))); | |||
@@ -187,6 +219,7 @@ public class SearchAction implements UsersWsAction { | |||
.setDeactivated(request.mandatoryParamAsBoolean(DEACTIVATED_PARAM)) | |||
.setPage(request.mandatoryParamAsInt(PAGE)) | |||
.setPageSize(pageSize) | |||
.setExternalLogin(request.param(EXTERNAL_IDENTITY)) | |||
.build(); | |||
} | |||
@@ -195,12 +228,14 @@ public class SearchAction implements UsersWsAction { | |||
private final Integer pageSize; | |||
private final String query; | |||
private final boolean deactivated; | |||
private final String externalLogin; | |||
private SearchRequest(Builder builder) { | |||
this.page = builder.page; | |||
this.pageSize = builder.pageSize; | |||
this.query = builder.query; | |||
this.deactivated = builder.deactivated; | |||
this.externalLogin = builder.externalLogin; | |||
} | |||
@CheckForNull | |||
@@ -233,6 +268,8 @@ public class SearchAction implements UsersWsAction { | |||
private String query; | |||
private boolean deactivated; | |||
private String externalLogin; | |||
private Builder() { | |||
// enforce factory method use | |||
} | |||
@@ -257,6 +294,11 @@ public class SearchAction implements UsersWsAction { | |||
return this; | |||
} | |||
public Builder setExternalLogin(@Nullable String externalLogin) { | |||
this.externalLogin = externalLogin; | |||
return this; | |||
} | |||
public SearchRequest build() { | |||
return new SearchRequest(this); | |||
} |
@@ -20,6 +20,7 @@ | |||
package org.sonar.server.user.ws; | |||
import java.util.stream.IntStream; | |||
import org.assertj.core.api.InstanceOfAssertFactories; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.sonar.api.server.ws.WebService; | |||
@@ -29,10 +30,12 @@ import org.sonar.db.DbTester; | |||
import org.sonar.db.user.GroupDto; | |||
import org.sonar.db.user.UserDto; | |||
import org.sonar.server.es.EsTester; | |||
import org.sonar.server.exceptions.ServerException; | |||
import org.sonar.server.issue.AvatarResolverImpl; | |||
import org.sonar.server.tester.UserSessionRule; | |||
import org.sonar.server.user.index.UserIndex; | |||
import org.sonar.server.user.index.UserIndexer; | |||
import org.sonar.server.ws.TestRequest; | |||
import org.sonar.server.ws.WsActionTester; | |||
import org.sonarqube.ws.Common.Paging; | |||
import org.sonarqube.ws.Users.SearchWsResponse; | |||
@@ -42,8 +45,10 @@ import static java.util.Arrays.asList; | |||
import static java.util.Collections.emptyList; | |||
import static java.util.Collections.singletonList; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | |||
import static org.assertj.core.api.Assertions.tuple; | |||
import static org.sonar.api.utils.DateUtils.formatDateTime; | |||
import static org.sonar.server.user.ws.SearchAction.EXTERNAL_IDENTITY; | |||
import static org.sonar.test.JsonAssert.assertJson; | |||
public class SearchActionTest { | |||
@@ -397,7 +402,86 @@ public class SearchActionTest { | |||
assertThat(action).isNotNull(); | |||
assertThat(action.isPost()).isFalse(); | |||
assertThat(action.responseExampleAsString()).isNotEmpty(); | |||
assertThat(action.params()).hasSize(4); | |||
assertThat(action.params()).hasSize(5); | |||
} | |||
@Test | |||
public void search_whenFilteringOnExternalIdentityAndNotAdmin_shouldThrow() { | |||
userSession.logIn(); | |||
TestRequest testRequest = ws.newRequest() | |||
.setParam(EXTERNAL_IDENTITY, "login"); | |||
assertForbiddenException(testRequest); | |||
} | |||
private static void assertForbiddenException(TestRequest testRequest) { | |||
assertThatThrownBy(() -> testRequest.executeProtobuf(SearchWsResponse.class)) | |||
.asInstanceOf(InstanceOfAssertFactories.type(ServerException.class)) | |||
.extracting(ServerException::httpCode) | |||
.isEqualTo(403); | |||
} | |||
@Test | |||
public void search_whenFilteringOnExternalIdentityAndMatch_shouldReturnMatchingUser() { | |||
userSession.logIn().setSystemAdministrator(); | |||
prepareUsersWithAndWithoutExternalLogin(); | |||
TestRequest testRequest = ws.newRequest() | |||
.setParam(EXTERNAL_IDENTITY, "user1"); | |||
assertThat(testRequest.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getExternalIdentity) | |||
.containsExactly("user1"); | |||
} | |||
@Test | |||
public void search_whenFilteringOnExternalIdentityAndNoMatch_shouldReturnNothing() { | |||
userSession.logIn().setSystemAdministrator(); | |||
prepareUsersWithAndWithoutExternalLogin(); | |||
TestRequest testRequest = ws.newRequest() | |||
.setParam(EXTERNAL_IDENTITY, "nomatch"); | |||
assertThat(testRequest.executeProtobuf(SearchWsResponse.class).getUsersList()).isEmpty(); | |||
} | |||
@Test | |||
public void search_whenFilteringOnExternalIdentityAndOtherCriteriaWitoutMatch_shouldReturnNothing() { | |||
userSession.logIn().setSystemAdministrator(); | |||
prepareUsersWithAndWithoutExternalLogin(); | |||
TestRequest testRequest = ws.newRequest() | |||
.setParam("deactivated", "true") | |||
.setParam(EXTERNAL_IDENTITY, "user1"); | |||
assertThat(testRequest.executeProtobuf(SearchWsResponse.class).getUsersList()).isEmpty(); | |||
} | |||
@Test | |||
public void search_whenFilteringOnExternalIdentityAndOtherCriteriaWithMatch_shouldReturnMatchingUser() { | |||
userSession.logIn().setSystemAdministrator(); | |||
prepareUsersWithAndWithoutExternalLogin(); | |||
TestRequest testRequest = ws.newRequest() | |||
.setParam("deactivated", "false") | |||
.setParam(EXTERNAL_IDENTITY, "user1"); | |||
assertThat(testRequest.executeProtobuf(SearchWsResponse.class).getUsersList()) | |||
.extracting(User::getExternalIdentity) | |||
.containsExactly("user1"); | |||
} | |||
private void prepareUsersWithAndWithoutExternalLogin() { | |||
db.users().insertUser(); | |||
db.users().insertUser(user -> user.setExternalLogin("user1")); | |||
db.users().insertUser(user -> user.setExternalLogin("USER1")); | |||
db.users().insertUser(user -> user.setExternalLogin("user1-oldaccount")); | |||
userIndexer.indexAll(); | |||
} | |||
} |