]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-17645 Support user commissioning and decomissioning through SCIM for Okta
authorWojtek Wajerowicz <115081248+wojciech-wajerowicz-sonarsource@users.noreply.github.com>
Thu, 24 Nov 2022 18:45:16 +0000 (19:45 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 8 Dec 2022 20:02:58 +0000 (20:02 +0000)
25 files changed:
server/sonar-db-core/src/main/java/org/sonar/db/version/SqTables.java
server/sonar-db-dao/src/main/java/org/sonar/db/DaoModule.java
server/sonar-db-dao/src/main/java/org/sonar/db/DbClient.java
server/sonar-db-dao/src/main/java/org/sonar/db/MyBatis.java
server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserDao.java [new file with mode: 0644]
server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserDto.java [new file with mode: 0644]
server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserMapper.java [new file with mode: 0644]
server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserQuery.java [new file with mode: 0644]
server/sonar-db-dao/src/main/java/org/sonar/db/scim/package-info.java [new file with mode: 0644]
server/sonar-db-dao/src/main/resources/org/sonar/db/scim/ScimUserMapper.xml [new file with mode: 0644]
server/sonar-db-dao/src/schema/schema-sq.ddl
server/sonar-db-dao/src/test/java/org/sonar/db/scim/ScimUserDaoTest.java [new file with mode: 0644]
server/sonar-db-dao/src/test/java/org/sonar/db/scim/ScimUserQueryTest.java [new file with mode: 0644]
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v98/CreateScimUsersTable.java [new file with mode: 0644]
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuid.java [new file with mode: 0644]
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v98/DbVersion98.java
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v98/CreateScimUsersTableTest.java [new file with mode: 0644]
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuidTest.java [new file with mode: 0644]
server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuidTest/schema.sql [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/DeactivateAction.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UserDeactivator.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/user/ws/DeactivateActionTest.java
server/sonar-webserver/src/main/java/org/sonar/server/platform/web/MasterServletFilter.java
server/sonar-webserver/src/test/java/org/sonar/server/platform/web/MasterServletFilterTest.java

index 7332a39dd6e8e74bcc66f6ba69f04faecd5198d0..6313648d80e661acf979f8966e1aee26c9de7d3e 100644 (file)
@@ -98,6 +98,7 @@ public final class SqTables {
     "rule_repositories",
     "scanner_analysis_cache",
     "schema_migrations",
+    "scim_users",
     "session_tokens",
     "snapshots",
     "users",
index 186b7ff51b4dac81bf941cb2e794459ccaf3b901..9c4e55abcfed00eb1b366bccbac34549cecd8dd8 100644 (file)
@@ -81,6 +81,7 @@ import org.sonar.db.rule.RuleDao;
 import org.sonar.db.rule.RuleRepositoryDao;
 import org.sonar.db.scannercache.ScannerAnalysisCacheDao;
 import org.sonar.db.schemamigration.SchemaMigrationDao;
+import org.sonar.db.scim.ScimUserDao;
 import org.sonar.db.source.FileSourceDao;
 import org.sonar.db.user.GroupDao;
 import org.sonar.db.user.GroupMembershipDao;
@@ -161,8 +162,9 @@ public class DaoModule extends Module {
     RuleRepositoryDao.class,
     SamlMessageIdDao.class,
     ScannerAnalysisCacheDao.class,
-    SnapshotDao.class,
     SchemaMigrationDao.class,
+    ScimUserDao.class,
+    SnapshotDao.class,
     SessionTokensDao.class,
     UserDao.class,
     UserDismissedMessagesDao.class,
index f4238569bda7e8fc61686f8100364a52e93d3f6b..3b01f339f24cb03dc59c0106ce8026cac138bb67 100644 (file)
@@ -81,6 +81,7 @@ import org.sonar.db.rule.RuleDao;
 import org.sonar.db.rule.RuleRepositoryDao;
 import org.sonar.db.scannercache.ScannerAnalysisCacheDao;
 import org.sonar.db.schemamigration.SchemaMigrationDao;
+import org.sonar.db.scim.ScimUserDao;
 import org.sonar.db.source.FileSourceDao;
 import org.sonar.db.user.GroupDao;
 import org.sonar.db.user.GroupMembershipDao;
@@ -172,6 +173,7 @@ public class DbClient {
   private final ApplicationProjectsDao applicationProjectsDao;
   private final ProjectBadgeTokenDao projectBadgeTokenDao;
   private final ScannerAnalysisCacheDao scannerAnalysisCacheDao;
+  private final ScimUserDao scimUserDao;
 
   public DbClient(Database database, MyBatis myBatis, DBSessions dbSessions, Dao... daos) {
     this.database = database;
@@ -254,6 +256,7 @@ public class DbClient {
     userDismissedMessagesDao = getDao(map, UserDismissedMessagesDao.class);
     applicationProjectsDao = getDao(map, ApplicationProjectsDao.class);
     scannerAnalysisCacheDao = getDao(map, ScannerAnalysisCacheDao.class);
+    scimUserDao = getDao(map, ScimUserDao.class);
   }
 
   public DbSession openSession(boolean batch) {
@@ -561,4 +564,8 @@ public class DbClient {
   public ScannerAnalysisCacheDao scannerAnalysisCacheDao() {
     return scannerAnalysisCacheDao;
   }
+
+  public ScimUserDao scimUserDao() {
+    return scimUserDao;
+  }
 }
index 515fd70c61a3c425a19b9ae6ac736585be3733a1..72f8c31e7703d9d9d3c8625a9723a37375cd7c69 100644 (file)
@@ -144,6 +144,7 @@ import org.sonar.db.rule.RuleRepositoryMapper;
 import org.sonar.db.scannercache.ScannerAnalysisCacheMapper;
 import org.sonar.db.schemamigration.SchemaMigrationDto;
 import org.sonar.db.schemamigration.SchemaMigrationMapper;
+import org.sonar.db.scim.ScimUserMapper;
 import org.sonar.db.source.FileSourceMapper;
 import org.sonar.db.user.GroupDto;
 import org.sonar.db.user.GroupMapper;
@@ -314,6 +315,7 @@ public class MyBatis {
       SamlMessageIdMapper.class,
       ScannerAnalysisCacheMapper.class,
       SchemaMigrationMapper.class,
+      ScimUserMapper.class,
       SessionTokenMapper.class,
       SnapshotMapper.class,
       UserDismissedMessagesMapper.class,
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserDao.java
new file mode 100644 (file)
index 0000000..7b270d7
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.scim;
+
+import java.util.List;
+import java.util.Optional;
+import org.sonar.core.util.UuidFactory;
+import org.apache.ibatis.session.RowBounds;
+import org.sonar.db.Dao;
+import org.sonar.db.DbSession;
+
+public class ScimUserDao implements Dao {
+  private final UuidFactory uuidFactory;
+
+  public ScimUserDao(UuidFactory uuidFactory) {
+    this.uuidFactory = uuidFactory;
+  }
+
+  public List<ScimUserDto> findAll(DbSession dbSession) {
+    return mapper(dbSession).findAll();
+  }
+
+  public Optional<ScimUserDto> findByScimUuid(DbSession dbSession, String scimUserUuid) {
+    return Optional.ofNullable(mapper(dbSession).findByScimUuid(scimUserUuid));
+  }
+
+  public Optional<ScimUserDto> findByUserUuid(DbSession dbSession, String userUuid) {
+    return Optional.ofNullable(mapper(dbSession).findByUserUuid(userUuid));
+  }
+
+  public ScimUserDto enableScimForUser(DbSession dbSession, String userUuid) {
+    ScimUserDto scimUserDto = new ScimUserDto(uuidFactory.create(), userUuid);
+    mapper(dbSession).insert(scimUserDto);
+    return scimUserDto;
+  }
+
+  public List<ScimUserDto> findScimUsers(DbSession dbSession, ScimUserQuery scimUserQuery, int offset, int limit) {
+    return mapper(dbSession).findScimUsers(scimUserQuery, new RowBounds(offset, limit));
+  }
+
+  public int countScimUsers(DbSession dbSession, ScimUserQuery scimUserQuery) {
+    return mapper(dbSession).countScimUsers(scimUserQuery);
+  }
+
+  private static ScimUserMapper mapper(DbSession session) {
+    return session.getMapper(ScimUserMapper.class);
+  }
+
+  public void deleteByUserUuid(DbSession dbSession, String userUuid) {
+    mapper(dbSession).deleteByUserUuid(userUuid);
+  }
+}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserDto.java b/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserDto.java
new file mode 100644 (file)
index 0000000..c92e4e5
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.scim;
+
+public class ScimUserDto {
+
+  private final String scimUserUuid;
+  private final String userUuid;
+
+  public ScimUserDto(String scimUserUuid, String userUuid) {
+    this.scimUserUuid = scimUserUuid;
+    this.userUuid = userUuid;
+  }
+
+  public String getScimUserUuid() {
+    return scimUserUuid;
+  }
+
+
+  public String getUserUuid() {
+    return userUuid;
+  }
+
+}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserMapper.java
new file mode 100644 (file)
index 0000000..75e1c0f
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.scim;
+
+import java.util.List;
+import javax.annotation.CheckForNull;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.session.RowBounds;
+
+public interface ScimUserMapper {
+
+  List<ScimUserDto> findAll();
+
+  @CheckForNull
+  ScimUserDto findByScimUuid(@Param("scimUserUuid") String scimUserUuid);
+
+  @CheckForNull
+  ScimUserDto findByUserUuid(@Param("userUuid") String userUuid);
+
+  void insert(@Param("scimUserDto") ScimUserDto scimUserDto);
+
+  List<ScimUserDto> findScimUsers(@Param("query") ScimUserQuery scimUserQuery, RowBounds rowBounds);
+
+  int countScimUsers(@Param("query") ScimUserQuery scimUserQuery);
+
+  void deleteByUserUuid(@Param("userUuid") String userUuid);
+}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserQuery.java b/server/sonar-db-dao/src/main/java/org/sonar/db/scim/ScimUserQuery.java
new file mode 100644 (file)
index 0000000..34f4fcb
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.scim;
+
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+
+import static java.util.regex.Pattern.CASE_INSENSITIVE;
+import static org.apache.commons.lang.StringUtils.isBlank;
+
+public class ScimUserQuery {
+  private static final Pattern USERNAME_FILTER_PATTERN = Pattern.compile("^userName\\s+eq\\s+\"([^\"]*?)\"$", CASE_INSENSITIVE);
+  private static final String UNSUPPORTED_FILTER = "Unsupported filter value: %s. Format should be 'userName eq \"username\"'";
+
+  private final String userName;
+
+  private ScimUserQuery(String userName) {
+    this.userName = userName;
+  }
+
+  @CheckForNull
+  public String getUserName() {
+    return userName;
+  }
+
+  public static ScimUserQuery empty() {
+    return builder().build();
+  }
+
+  public static ScimUserQuery fromScimFilter(@Nullable String filter) {
+    if (isBlank(filter)) {
+      return empty();
+    }
+
+    String userName = getUserNameFromFilter(filter)
+      .orElseThrow(() -> new IllegalStateException(String.format(UNSUPPORTED_FILTER, filter)));
+
+    return builder().userName(userName).build();
+  }
+
+  private static Optional<String> getUserNameFromFilter(String filter) {
+    Matcher matcher = USERNAME_FILTER_PATTERN.matcher(filter.trim());
+    return matcher.find()
+      ? Optional.of(matcher.group(1))
+      : Optional.empty();
+  }
+
+  public static ScimUserQueryBuilder builder() {
+    return new ScimUserQueryBuilder();
+  }
+
+  public static final class ScimUserQueryBuilder {
+
+    private String userName;
+
+    private ScimUserQueryBuilder() {
+    }
+
+    public ScimUserQueryBuilder userName(String userName) {
+      this.userName = userName;
+      return this;
+    }
+
+    public ScimUserQuery build() {
+      return new ScimUserQuery(userName);
+    }
+
+  }
+}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/scim/package-info.java b/server/sonar-db-dao/src/main/java/org/sonar/db/scim/package-info.java
new file mode 100644 (file)
index 0000000..db44d63
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.db.scim;
+
+import javax.annotation.ParametersAreNonnullByDefault;
+
diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/scim/ScimUserMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/scim/ScimUserMapper.xml
new file mode 100644 (file)
index 0000000..2c99a13
--- /dev/null
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "mybatis-3-mapper.dtd">
+
+<mapper namespace="org.sonar.db.scim.ScimUserMapper">
+
+  <sql id="scimUsersColumns">
+      scim_uuid as scimUserUuid,
+      user_uuid as userUuid
+  </sql>
+
+  <select id="findAll" resultType="org.sonar.db.scim.ScimUserDto">
+    select
+    <include refid="scimUsersColumns"/>
+      from scim_users
+  </select>
+
+  <select id="findByScimUuid" parameterType="String" resultType="org.sonar.db.scim.ScimUserDto">
+    select
+    <include refid="scimUsersColumns"/>
+      from scim_users
+    where
+      scim_uuid = #{scimUserUuid,jdbcType=VARCHAR}
+  </select>
+
+  <select id="findByUserUuid" parameterType="String" resultType="org.sonar.db.scim.ScimUserDto">
+    select
+    <include refid="scimUsersColumns"/>
+      from scim_users
+    where
+      user_uuid = #{userUuid,jdbcType=VARCHAR}
+  </select>
+
+  <insert id="insert" parameterType="map" useGeneratedKeys="false">
+    insert into scim_users (
+      scim_uuid,
+      user_uuid
+    ) values (
+      #{scimUserDto.scimUserUuid,jdbcType=VARCHAR},
+      #{scimUserDto.userUuid,jdbcType=VARCHAR}
+    )
+  </insert>
+
+  <select id="findScimUsers" resultType="org.sonar.db.scim.ScimUserDto">
+    select
+    <include refid="scimUsersColumns"/>
+    <include refid="sqlSelectByQuery"/>
+    order by s.scim_uuid asc
+  </select>
+
+  <select id="countScimUsers" resultType="int">
+    select count(1)
+    <include refid="sqlSelectByQuery"/>
+  </select>
+
+  <sql id="sqlSelectByQuery">
+    from scim_users s
+    <if test="query.userName != null">
+      inner join users u on u.uuid=s.user_uuid
+        where lower(u.external_id) like lower(#{query.userName,jdbcType=VARCHAR}) escape '/'
+    </if>
+  </sql>
+
+  <delete id="deleteByUserUuid" parameterType="String">
+    delete from scim_users where user_uuid = #{userUuid, jdbcType=VARCHAR}
+  </delete>
+
+</mapper>
+
index 023646e06ff87dd947b5b683b475ae5bece452c8..eb1660e6ef68f7d835122ddee4e1482369dbd0cf 100644 (file)
@@ -920,6 +920,13 @@ CREATE TABLE "SCANNER_ANALYSIS_CACHE"(
 );
 ALTER TABLE "SCANNER_ANALYSIS_CACHE" ADD CONSTRAINT "PK_SCANNER_ANALYSIS_CACHE" PRIMARY KEY("BRANCH_UUID");
 
+CREATE TABLE "SCIM_USERS"(
+    "SCIM_UUID" CHARACTER VARYING(40) NOT NULL,
+    "USER_UUID" CHARACTER VARYING(40) NOT NULL
+);
+ALTER TABLE "SCIM_USERS" ADD CONSTRAINT "PK_SCIM_USERS" PRIMARY KEY("SCIM_UUID");
+CREATE UNIQUE INDEX "UNIQ_SCIM_USERS_USER_UUID" ON "SCIM_USERS"("USER_UUID" NULLS FIRST);
+
 CREATE TABLE "SESSION_TOKENS"(
     "UUID" CHARACTER VARYING(40) NOT NULL,
     "USER_UUID" CHARACTER VARYING(255) NOT NULL,
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/scim/ScimUserDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/scim/ScimUserDaoTest.java
new file mode 100644 (file)
index 0000000..588b9e6
--- /dev/null
@@ -0,0 +1,272 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.scim;
+
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.db.DbSession;
+import org.sonar.db.DbTester;
+import org.sonar.db.user.UserDto;
+
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Fail.fail;
+
+@RunWith(DataProviderRunner.class)
+public class ScimUserDaoTest {
+
+  @Rule
+  public DbTester db = DbTester.create();
+  private final DbSession dbSession = db.getSession();
+  private final ScimUserDao scimUserDao = db.getDbClient().scimUserDao();
+
+  @Test
+  public void findAll_ifNoData_returnsEmptyList() {
+    assertThat(scimUserDao.findAll(dbSession)).isEmpty();
+  }
+
+  @Test
+  public void findAll_returnsAllEntries() {
+    ScimUserTestData scimUser1TestData = insertScimUser("scimUser1");
+    ScimUserTestData scimUser2TestData = insertScimUser("scimUser2");
+
+    List<ScimUserDto> scimUserDtos = scimUserDao.findAll(dbSession);
+
+    assertThat(scimUserDtos).hasSize(2)
+      .map(scimUserDto -> new ScimUserTestData(scimUserDto.getScimUserUuid(), scimUserDto.getUserUuid()))
+      .containsExactlyInAnyOrder(scimUser1TestData, scimUser2TestData);
+
+  }
+
+  @Test
+  public void findByScimUuid_whenScimUuidNotFound_shouldReturnEmptyOptional() {
+    assertThat(scimUserDao.findByScimUuid(dbSession, "unknownId")).isEmpty();
+  }
+
+  @Test
+  public void findByScimUuid_whenScimUuidFound_shouldReturnDto() {
+    ScimUserTestData scimUser1TestData = insertScimUser("scimUser1");
+    insertScimUser("scimUser2");
+
+    ScimUserDto scimUserDto = scimUserDao.findByScimUuid(dbSession, scimUser1TestData.getScimUserUuid())
+      .orElseGet(() -> fail("User not found"));
+
+    assertThat(scimUserDto.getScimUserUuid()).isEqualTo(scimUser1TestData.getScimUserUuid());
+    assertThat(scimUserDto.getUserUuid()).isEqualTo(scimUser1TestData.getUserUuid());
+  }
+
+  @Test
+  public void findByUserUuid_whenScimUuidNotFound_shouldReturnEmptyOptional() {
+    assertThat(scimUserDao.findByUserUuid(dbSession, "unknownId")).isEmpty();
+  }
+
+  @Test
+  public void findByUserUuid_whenScimUuidFound_shouldReturnDto() {
+    ScimUserTestData scimUser1TestData = insertScimUser("scimUser1");
+    insertScimUser("scimUser2");
+
+    ScimUserDto scimUserDto = scimUserDao.findByUserUuid(dbSession, scimUser1TestData.getUserUuid())
+      .orElseGet(() -> fail("User not found"));
+
+    assertThat(scimUserDto.getScimUserUuid()).isEqualTo(scimUser1TestData.getScimUserUuid());
+    assertThat(scimUserDto.getUserUuid()).isEqualTo(scimUser1TestData.getUserUuid());
+  }
+
+  @DataProvider
+  public static Object[][] paginationData() {
+    return new Object[][] {
+      {5, 0, 20, List.of("1", "2", "3", "4", "5")},
+      {9, 0, 5, List.of("1", "2", "3", "4", "5")},
+      {9, 3, 3, List.of("4", "5", "6")},
+      {9, 7, 3, List.of("8", "9")},
+      {5, 5, 20, List.of()},
+      {5, 0, 0, List.of()}
+    };
+  }
+
+  @Test
+  @UseDataProvider("paginationData")
+  public void findScimUsers_whenPaginationAndStartIndex_shouldReturnTheCorrectNumberOfScimUsers(int totalScimUsers, int offset, int pageSize, List<String> expectedScimUserUuids) {
+    generateScimUsers(totalScimUsers);
+
+    List<ScimUserDto> scimUserDtos = scimUserDao.findScimUsers(dbSession, ScimUserQuery.empty(), offset, pageSize);
+
+    List<String> scimUsersUuids = toScimUsersUuids(scimUserDtos);
+    assertThat(scimUsersUuids).containsExactlyElementsOf(expectedScimUserUuids);
+  }
+
+  private List<String> toScimUsersUuids(Collection<ScimUserDto> scimUserDtos) {
+    return scimUserDtos.stream()
+      .map(ScimUserDto::getScimUserUuid)
+      .collect(Collectors.toList());
+  }
+
+  @Test
+  public void countScimUsers_shouldReturnTheTotalNumberOfScimUsers() {
+    int totalScimUsers = 15;
+    generateScimUsers(totalScimUsers);
+
+    assertThat(scimUserDao.countScimUsers(dbSession, ScimUserQuery.empty())).isEqualTo(totalScimUsers);
+  }
+
+  @Test
+  public void countScimUsers_shouldReturnZero_whenNoScimUsers() {
+    assertThat(scimUserDao.countScimUsers(dbSession, ScimUserQuery.empty())).isZero();
+  }
+
+  @Test
+  public void countScimUsers_shoudReturnZero_whenNoScimUsersMatchesQuery() {
+    int totalScimUsers = 15;
+    generateScimUsers(totalScimUsers);
+    ScimUserQuery scimUserQuery = ScimUserQuery.builder().userName("jean.okta").build();
+
+    assertThat(scimUserDao.countScimUsers(dbSession, scimUserQuery)).isZero();
+  }
+
+  @Test
+  public void countScimUsers_shoudReturnCorrectNumberOfScimUser_whenFilteredByScimUserName() {
+    inserScimUsersWithUsers(List.of("TEST_A", "TEST_B", "TEST_B_BIS", "TEST_C", "TEST_D"));
+    ScimUserQuery scimUserQuery = ScimUserQuery.builder().userName("test_b").build();
+
+    assertThat(scimUserDao.countScimUsers(dbSession, scimUserQuery)).isEqualTo(1);
+  }
+
+  private void generateScimUsers(int totalScimUsers) {
+    List<ScimUserTestData> allScimUsers = Stream.iterate(1, i -> i + 1)
+      .map(i -> insertScimUser(i.toString()))
+      .limit(totalScimUsers)
+      .collect(Collectors.toList());
+    assertThat(allScimUsers).hasSize(totalScimUsers);
+  }
+
+  @Test
+  public void enableScimForUser_addsUserToScimUsers() {
+    ScimUserDto scimUserDto = scimUserDao.enableScimForUser(dbSession, "sqUser1");
+
+    assertThat(scimUserDto.getScimUserUuid()).isNotBlank();
+    ScimUserDto actualScimUserDto = scimUserDao.findByScimUuid(dbSession, scimUserDto.getScimUserUuid()).orElseThrow();
+    assertThat(scimUserDto.getScimUserUuid()).isEqualTo(actualScimUserDto.getScimUserUuid());
+    assertThat(scimUserDto.getUserUuid()).isEqualTo(actualScimUserDto.getUserUuid());
+  }
+
+  @DataProvider
+  public static Object[][] filterData() {
+    return new Object[][] {
+      {"test_user", List.of("test_user", "Test_USEr", "xxx.test_user.yyy", "test_xxx_user"), List.of("1", "2")},
+      {"TEST_USER", List.of("test_user", "Test_USEr", "xxx.test_user.yyy", "test_xxx_user"), List.of("1", "2")},
+      {"test_user_x", List.of("test_user"), List.of()},
+      {"test_x_user", List.of("test_user"), List.of()},
+    };
+  }
+
+  @Test
+  @UseDataProvider("filterData")
+  public void findScimUsers_whenFilteringByUserName_shouldReturnTheExpectedScimUsers(String search, List<String> userLogins, List<String> expectedScimUserUuids) {
+    inserScimUsersWithUsers(userLogins);
+    ScimUserQuery query = ScimUserQuery.builder().userName(search).build();
+
+    List<ScimUserDto> scimUsersByQuery = scimUserDao.findScimUsers(dbSession, query, 0, 100);
+
+    List<String> scimUsersUuids = toScimUsersUuids(scimUsersByQuery);
+    assertThat(scimUsersUuids).containsExactlyElementsOf(expectedScimUserUuids);
+  }
+
+  @Test
+  public void deleteFromUserUuid_shouldDeleteScimUser() {
+    ScimUserTestData scimUserTestData = insertScimUser("scimUser");
+
+    scimUserDao.deleteByUserUuid(dbSession, scimUserTestData.getUserUuid());
+
+    assertThat(scimUserDao.findAll(dbSession)).isEmpty();
+  }
+
+  @Test
+  public void deleteFromUserUuid_shouldNotFail_whenNoUser() {
+    assertThatCode(() -> scimUserDao.deleteByUserUuid(dbSession, randomAlphanumeric(6))).doesNotThrowAnyException();
+  }
+
+  private void inserScimUsersWithUsers(List<String> userLogins) {
+    IntStream.range(0, userLogins.size())
+      .forEachOrdered(i -> insertScimUserWithUser(userLogins.get(i), String.valueOf(i + 1)));
+  }
+
+  private void insertScimUserWithUser(String userLogin, String scimUuid) {
+    UserDto userDto = db.users().insertUser(u -> u.setExternalId(userLogin));
+    insertScimUser(scimUuid, userDto.getUuid());
+  }
+
+  private ScimUserTestData insertScimUser(String scimUserUuid) {
+    return insertScimUser(scimUserUuid, randomAlphanumeric(40));
+  }
+
+  private ScimUserTestData insertScimUser(String scimUserUuid, String userUuid) {
+    ScimUserTestData scimUserTestData = new ScimUserTestData(scimUserUuid, userUuid);
+    Map<String, Object> data = Map.of("scim_uuid", scimUserTestData.getScimUserUuid(), "user_uuid", scimUserTestData.getUserUuid());
+    db.executeInsert("scim_users", data);
+
+    return scimUserTestData;
+  }
+
+  private static class ScimUserTestData {
+
+    private final String scimUserUuid;
+    private final String userUuid;
+
+    private ScimUserTestData(String scimUserUuid, String userUuid) {
+      this.scimUserUuid = scimUserUuid;
+      this.userUuid = userUuid;
+    }
+
+    private String getScimUserUuid() {
+      return scimUserUuid;
+    }
+
+    private String getUserUuid() {
+      return userUuid;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o)
+        return true;
+      if (o == null || getClass() != o.getClass())
+        return false;
+      ScimUserTestData that = (ScimUserTestData) o;
+      return getScimUserUuid().equals(that.getScimUserUuid()) && getUserUuid().equals(that.getUserUuid());
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(getScimUserUuid(), getUserUuid());
+    }
+  }
+}
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/scim/ScimUserQueryTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/scim/ScimUserQueryTest.java
new file mode 100644 (file)
index 0000000..175dd28
--- /dev/null
@@ -0,0 +1,82 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.scim;
+
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@RunWith(DataProviderRunner.class)
+public class ScimUserQueryTest {
+
+  @DataProvider
+  public static Object[][] filterData() {
+    ScimUserQuery queryWithUserName = ScimUserQuery.builder().userName("test.user@okta.local").build();
+    ScimUserQuery emptyQuery = ScimUserQuery.builder().build();
+    return new Object[][]{
+      {"userName eq \"test.user@okta.local\"", queryWithUserName},
+      {"  userName eq \"test.user@okta.local\"  ", queryWithUserName},
+      {"userName     eq     \"test.user@okta.local\"", queryWithUserName},
+      {"UsERnaMe eq \"test.user@okta.local\"", queryWithUserName},
+      {"userName EQ \"test.user@okta.local\"", queryWithUserName},
+      {null, emptyQuery},
+      {"", emptyQuery}
+    };
+  }
+
+  @Test
+  @UseDataProvider("filterData")
+  public void fromScimFilter_shouldCorrectlyResolveProperties(String filter, ScimUserQuery expected) {
+    ScimUserQuery scimUserQuery = ScimUserQuery.fromScimFilter(filter);
+
+    assertThat(scimUserQuery).usingRecursiveComparison().isEqualTo(expected);
+  }
+
+  @DataProvider
+  public static Object[][] unsupportedFilterData() {
+    return new Object[][]{
+      {"otherProp eq \"test.user@okta.local\""},
+      {"userName eq \"test.user@okta.local\" or userName eq \"test.user2@okta.local\""},
+      {"userName eq \"test.user@okta.local\" and email eq \"test.user2@okta.local\""},
+      {"userName eq \"test.user@okta.local\"xjdkfgldkjfhg"}
+    };
+  }
+
+  @Test
+  @UseDataProvider("unsupportedFilterData")
+  public void fromScimFilter_shouldThrowAnException(String filter) {
+    assertThatThrownBy(() -> ScimUserQuery.fromScimFilter(filter))
+      .isInstanceOf(IllegalStateException.class)
+      .hasMessage(String.format("Unsupported filter value: %s. Format should be 'userName eq \"username\"'", filter));
+  }
+
+  @Test
+  public void empty_shouldHaveNoProperties() {
+    ScimUserQuery scimUserQuery = ScimUserQuery.empty();
+
+    assertThat(scimUserQuery.getUserName()).isNull();
+  }
+
+}
diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v98/CreateScimUsersTable.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v98/CreateScimUsersTable.java
new file mode 100644 (file)
index 0000000..51fdc36
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.version.v98;
+
+import java.sql.SQLException;
+import org.sonar.db.Database;
+import org.sonar.server.platform.db.migration.sql.CreateTableBuilder;
+import org.sonar.server.platform.db.migration.step.CreateTableChange;
+
+import static org.sonar.server.platform.db.migration.def.VarcharColumnDef.UUID_SIZE;
+import static org.sonar.server.platform.db.migration.def.VarcharColumnDef.newVarcharColumnDefBuilder;
+
+public class CreateScimUsersTable extends CreateTableChange {
+
+  static final String TABLE_NAME = "scim_users";
+
+  public CreateScimUsersTable(Database db) {
+    super(db, TABLE_NAME);
+  }
+
+  @Override
+  public void execute(Context context, String tableName) throws SQLException {
+    context.execute(new CreateTableBuilder(getDialect(), tableName)
+      .addPkColumn(newVarcharColumnDefBuilder().setColumnName("scim_uuid").setIsNullable(false).setLimit(UUID_SIZE).build())
+      .addColumn(newVarcharColumnDefBuilder().setColumnName("user_uuid").setIsNullable(false).setLimit(UUID_SIZE).build())
+      .build());
+  }
+}
diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuid.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuid.java
new file mode 100644 (file)
index 0000000..b1fa79c
--- /dev/null
@@ -0,0 +1,61 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.version.v98;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.sql.Connection;
+import java.sql.SQLException;
+import org.sonar.db.Database;
+import org.sonar.db.DatabaseUtils;
+import org.sonar.server.platform.db.migration.sql.CreateIndexBuilder;
+import org.sonar.server.platform.db.migration.step.DdlChange;
+
+import static org.sonar.server.platform.db.migration.version.v98.CreateScimUsersTable.TABLE_NAME;
+
+public class CreateUniqueIndexForScimUserUuid extends DdlChange {
+
+  @VisibleForTesting
+  static final String COLUMN_NAME = "user_uuid";
+
+  @VisibleForTesting
+  static final String INDEX_NAME = "uniq_scim_users_user_uuid";
+
+  public CreateUniqueIndexForScimUserUuid(Database db) {
+    super(db);
+  }
+
+  @Override
+  public void execute(Context context) throws SQLException {
+    try (Connection connection = getDatabase().getDataSource().getConnection()) {
+      createUserUuidUniqueIndex(context, connection);
+    }
+  }
+
+  private static void createUserUuidUniqueIndex(Context context, Connection connection) {
+    if (!DatabaseUtils.indexExistsIgnoreCase(TABLE_NAME, INDEX_NAME, connection)) {
+      context.execute(new CreateIndexBuilder()
+        .setTable(TABLE_NAME)
+        .setName(INDEX_NAME)
+        .addColumn(COLUMN_NAME)
+        .setUnique(true)
+        .build());
+    }
+  }
+}
index b06c1e7b0d84c9f4a3e4c387e3bfcade5a212be0..1054317734de60ce23df073543884a60519aba5d 100644 (file)
@@ -32,6 +32,7 @@ public class DbVersion98 implements DbVersion {
       .add(6703, "Drop project measure variation column", DropProjectMeasureVariationColumn.class)
       .add(6704, "Update sonar-users group description", UpsertSonarUsersDescription.class)
       .add(6705, "Add message_formattings column to issue table", AddMessageFormattingsColumnToIssueTable.class)
-      ;
+      .add(6706, "Create scim_users table", CreateScimUsersTable.class)
+      .add(6707, "Create unique index on scim_users.user_uuid", CreateUniqueIndexForScimUserUuid.class);
   }
 }
diff --git a/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v98/CreateScimUsersTableTest.java b/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v98/CreateScimUsersTableTest.java
new file mode 100644 (file)
index 0000000..1b89048
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.version.v98;
+
+import java.sql.SQLException;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.db.CoreDbTester;
+
+import static org.sonar.server.platform.db.migration.version.v98.CreateScimUsersTable.TABLE_NAME;
+
+public class CreateScimUsersTableTest {
+
+  @Rule
+  public final CoreDbTester db = CoreDbTester.createEmpty();
+
+  private final CreateScimUsersTable underTest = new CreateScimUsersTable(db.database());
+
+  @Test
+  public void migration_should_create_a_table() throws SQLException {
+    db.assertTableDoesNotExist(TABLE_NAME);
+
+    underTest.execute();
+
+    db.assertTableExists(TABLE_NAME);
+    db.assertPrimaryKey(TABLE_NAME, "pk_scim_users", "scim_uuid");
+  }
+
+  @Test
+  public void migration_should_be_reentrant() throws SQLException {
+    db.assertTableDoesNotExist(TABLE_NAME);
+
+    underTest.execute();
+    // re-entrant
+    underTest.execute();
+
+    db.assertTableExists(TABLE_NAME);
+  }
+}
diff --git a/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuidTest.java b/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuidTest.java
new file mode 100644 (file)
index 0000000..0ca0c7e
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.version.v98;
+
+import java.sql.SQLException;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.db.CoreDbTester;
+
+import static org.sonar.server.platform.db.migration.version.v98.CreateScimUsersTable.TABLE_NAME;
+import static org.sonar.server.platform.db.migration.version.v98.CreateUniqueIndexForScimUserUuid.COLUMN_NAME;
+import static org.sonar.server.platform.db.migration.version.v98.CreateUniqueIndexForScimUserUuid.INDEX_NAME;
+
+public class CreateUniqueIndexForScimUserUuidTest {
+
+  @Rule
+  public final CoreDbTester db = CoreDbTester.createForSchema(CreateUniqueIndexForScimUserUuidTest.class, "schema.sql");
+
+  private final CreateUniqueIndexForScimUserUuid underTest = new CreateUniqueIndexForScimUserUuid(db.database());
+
+  @Test
+  public void migration_should_create_index() throws SQLException {
+    db.assertIndexDoesNotExist(TABLE_NAME, INDEX_NAME);
+
+    underTest.execute();
+
+    db.assertUniqueIndex(TABLE_NAME, INDEX_NAME, COLUMN_NAME);
+  }
+
+  @Test
+  public void migration_should_be_reentrant() throws SQLException {
+    db.assertIndexDoesNotExist(TABLE_NAME, INDEX_NAME);
+
+    underTest.execute();
+    underTest.execute();
+
+    db.assertUniqueIndex(TABLE_NAME, INDEX_NAME, COLUMN_NAME);
+  }
+}
diff --git a/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuidTest/schema.sql b/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v98/CreateUniqueIndexForScimUserUuidTest/schema.sql
new file mode 100644 (file)
index 0000000..4057977
--- /dev/null
@@ -0,0 +1,5 @@
+CREATE TABLE "SCIM_USERS"(
+    "SCIM_UUID" CHARACTER VARYING(40) NOT NULL,
+    "USER_UUID" CHARACTER VARYING(40) NOT NULL
+);
+ALTER TABLE "SCIM_USERS" ADD CONSTRAINT "PK_SCIM_USERS" PRIMARY KEY("SCIM_UUID");
index b8428a30331b4baae50af8963e88885f8ef45036..7dfab14cda1321238a5d3f64d22a8eddd32d39c2 100644 (file)
@@ -28,14 +28,10 @@ import org.sonar.api.server.ws.WebService.NewAction;
 import org.sonar.api.utils.text.JsonWriter;
 import org.sonar.db.DbClient;
 import org.sonar.db.DbSession;
-import org.sonar.db.property.PropertyQuery;
 import org.sonar.db.user.UserDto;
 import org.sonar.server.user.UserSession;
-import org.sonar.server.user.index.UserIndexer;
 
 import static java.util.Collections.singletonList;
-import static org.sonar.api.CoreProperties.DEFAULT_ISSUE_ASSIGNEE;
-import static org.sonar.db.permission.GlobalPermission.ADMINISTER;
 import static org.sonar.server.exceptions.BadRequestException.checkRequest;
 import static org.sonar.server.exceptions.NotFoundException.checkFound;
 
@@ -45,17 +41,16 @@ public class DeactivateAction implements UsersWsAction {
   private static final String PARAM_ANONYMIZE = "anonymize";
 
   private final DbClient dbClient;
-  private final UserIndexer userIndexer;
   private final UserSession userSession;
   private final UserJsonWriter userWriter;
-  private final UserAnonymizer userAnonymizer;
+  private final UserDeactivator userDeactivator;
 
-  public DeactivateAction(DbClient dbClient, UserIndexer userIndexer, UserSession userSession, UserJsonWriter userWriter, UserAnonymizer userAnonymizer) {
+  public DeactivateAction(DbClient dbClient, UserSession userSession, UserJsonWriter userWriter,
+    UserDeactivator userDeactivator) {
     this.dbClient = dbClient;
-    this.userIndexer = userIndexer;
     this.userSession = userSession;
     this.userWriter = userWriter;
-    this.userAnonymizer = userAnonymizer;
+    this.userDeactivator = userDeactivator;
   }
 
   @Override
@@ -89,33 +84,11 @@ public class DeactivateAction implements UsersWsAction {
     checkRequest(!login.equals(userSession.getLogin()), "Self-deactivation is not possible");
 
     try (DbSession dbSession = dbClient.openSession(false)) {
-      UserDto user = dbClient.userDao().selectByLogin(dbSession, login);
-      checkFound(user, "User '%s' doesn't exist", login);
-
-      ensureNotLastAdministrator(dbSession, user);
-
-      String userUuid = user.getUuid();
-      dbClient.userTokenDao().deleteByUser(dbSession, user);
-      dbClient.propertiesDao().deleteByKeyAndValue(dbSession, DEFAULT_ISSUE_ASSIGNEE, user.getLogin());
-      dbClient.propertiesDao().deleteByQuery(dbSession, PropertyQuery.builder().setUserUuid(userUuid).build());
-      dbClient.userGroupDao().deleteByUserUuid(dbSession, user);
-      dbClient.userPermissionDao().deleteByUserUuid(dbSession, user);
-      dbClient.permissionTemplateDao().deleteUserPermissionsByUserUuid(dbSession, userUuid, user.getLogin());
-      dbClient.qProfileEditUsersDao().deleteByUser(dbSession, user);
-      dbClient.almPatDao().deleteByUser(dbSession, user);
-      dbClient.sessionTokensDao().deleteByUser(dbSession, user);
-      dbClient.userDismissedMessagesDao().deleteByUser(dbSession, user);
-      dbClient.qualityGateUserPermissionDao().deleteByUser(dbSession, user);
-
-      if (request.mandatoryParamAsBoolean(PARAM_ANONYMIZE)) {
-        userAnonymizer.anonymize(dbSession, user);
-        dbClient.userDao().update(dbSession, user);
-      }
-
-      dbClient.userDao().deactivateUser(dbSession, user);
-
-      userIndexer.commitAndIndex(dbSession, user);
-      writeResponse(response, user.getLogin());
+      boolean shouldAnonymize = request.mandatoryParamAsBoolean(PARAM_ANONYMIZE);
+      UserDto userDto = shouldAnonymize
+        ? userDeactivator.deactivateUserWithAnonymization(dbSession, login)
+        : userDeactivator.deactivateUser(dbSession, login);
+      writeResponse(response, userDto.getLogin());
     }
   }
 
@@ -136,9 +109,6 @@ public class DeactivateAction implements UsersWsAction {
     }
   }
 
-  private void ensureNotLastAdministrator(DbSession dbSession, UserDto user) {
-    boolean isLastAdmin = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingUser(dbSession, ADMINISTER.getKey(), user.getUuid()) == 0;
-    checkRequest(!isLastAdmin, "User is last administrator, and cannot be deactivated");
-  }
+
 
 }
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UserDeactivator.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UserDeactivator.java
new file mode 100644 (file)
index 0000000..fe24a3b
--- /dev/null
@@ -0,0 +1,103 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.server.user.ws;
+
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.property.PropertyQuery;
+import org.sonar.db.user.UserDto;
+import org.sonar.server.user.UserSession;
+import org.sonar.server.user.index.UserIndexer;
+
+import static org.sonar.api.CoreProperties.DEFAULT_ISSUE_ASSIGNEE;
+import static org.sonar.db.permission.GlobalPermission.ADMINISTER;
+import static org.sonar.server.exceptions.BadRequestException.checkRequest;
+import static org.sonar.server.exceptions.NotFoundException.checkFound;
+
+public class UserDeactivator {
+  private final DbClient dbClient;
+  private final UserIndexer userIndexer;
+  private final UserSession userSession;
+  private final UserAnonymizer userAnonymizer;
+
+  public UserDeactivator(DbClient dbClient, UserIndexer userIndexer, UserSession userSession, UserAnonymizer userAnonymizer) {
+    this.dbClient = dbClient;
+    this.userIndexer = userIndexer;
+    this.userSession = userSession;
+    this.userAnonymizer = userAnonymizer;
+  }
+
+  public UserDto deactivateUser(DbSession dbSession, String login) {
+    UserDto user = doBeforeDeactivation(dbSession, login);
+    deactivateUser(dbSession, user);
+    return user;
+  }
+
+  public UserDto deactivateUserWithAnonymization(DbSession dbSession, String login) {
+    UserDto user = doBeforeDeactivation(dbSession, login);
+    anonymizeUser(dbSession, user);
+    deactivateUser(dbSession, user);
+    return user;
+  }
+
+  private UserDto doBeforeDeactivation(DbSession dbSession, String login) {
+    checkRequest(!login.equals(userSession.getLogin()), "Self-deactivation is not possible");
+    UserDto user = getUserOrThrow(dbSession, login);
+    ensureNotLastAdministrator(dbSession, user);
+    deleteRelatedData(dbSession, user);
+    return user;
+  }
+
+  private UserDto getUserOrThrow(DbSession dbSession, String login) {
+    UserDto user = dbClient.userDao().selectByLogin(dbSession, login);
+    return checkFound(user, "User '%s' doesn't exist", login);
+  }
+
+  private void ensureNotLastAdministrator(DbSession dbSession, UserDto user) {
+    boolean isLastAdmin = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingUser(dbSession, ADMINISTER.getKey(), user.getUuid()) == 0;
+    checkRequest(!isLastAdmin, "User is last administrator, and cannot be deactivated");
+  }
+
+  private void deleteRelatedData(DbSession dbSession, UserDto user) {
+    String userUuid = user.getUuid();
+    dbClient.userTokenDao().deleteByUser(dbSession, user);
+    dbClient.propertiesDao().deleteByKeyAndValue(dbSession, DEFAULT_ISSUE_ASSIGNEE, user.getLogin());
+    dbClient.propertiesDao().deleteByQuery(dbSession, PropertyQuery.builder().setUserUuid(userUuid).build());
+    dbClient.userGroupDao().deleteByUserUuid(dbSession, user);
+    dbClient.userPermissionDao().deleteByUserUuid(dbSession, user);
+    dbClient.permissionTemplateDao().deleteUserPermissionsByUserUuid(dbSession, userUuid, user.getLogin());
+    dbClient.qProfileEditUsersDao().deleteByUser(dbSession, user);
+    dbClient.almPatDao().deleteByUser(dbSession, user);
+    dbClient.sessionTokensDao().deleteByUser(dbSession, user);
+    dbClient.userDismissedMessagesDao().deleteByUser(dbSession, user);
+    dbClient.qualityGateUserPermissionDao().deleteByUser(dbSession, user);
+  }
+
+  private void anonymizeUser(DbSession dbSession, UserDto user) {
+    userAnonymizer.anonymize(dbSession, user);
+    dbClient.userDao().update(dbSession, user);
+    dbClient.scimUserDao().deleteByUserUuid(dbSession, user.getUuid());
+  }
+
+  private void deactivateUser(DbSession dbSession, UserDto user) {
+    dbClient.userDao().deactivateUser(dbSession, user);
+    userIndexer.commitAndIndex(dbSession, user);
+  }
+}
index 0e6c86eff99d9ea90c270b3ad8f14e8b29556d1b..b997c974d652cfc02756e08f99ff52fffc2453bf 100644 (file)
@@ -32,6 +32,7 @@ public class UsersWsModule extends Module {
       UpdateAction.class,
       UpdateLoginAction.class,
       DeactivateAction.class,
+      UserDeactivator.class,
       DismissSonarlintAdAction.class,
       ChangePasswordAction.class,
       CurrentAction.class,
index ab3c6f72531e0ac0f979f53509fd0f533c588c96..a6c821cda3a557ce5e2130821fc4652718f03ecc 100644 (file)
@@ -62,10 +62,6 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.elasticsearch.index.query.QueryBuilders.boolQuery;
 import static org.elasticsearch.index.query.QueryBuilders.termQuery;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
 import static org.sonar.api.web.UserRole.CODEVIEWER;
 import static org.sonar.api.web.UserRole.USER;
 import static org.sonar.db.permission.GlobalPermission.ADMINISTER;
@@ -91,7 +87,8 @@ public class DeactivateActionTest {
   private final UserIndexer userIndexer = new UserIndexer(dbClient, es.client());
   private final DbSession dbSession = db.getSession();
   private final UserAnonymizer userAnonymizer = new UserAnonymizer(db.getDbClient(), () -> "anonymized");
-  private final WsActionTester ws = new WsActionTester(new DeactivateAction(dbClient, userIndexer, userSession, new UserJsonWriter(userSession), userAnonymizer));
+  private final UserDeactivator userDeactivator = new UserDeactivator(dbClient, userIndexer, userSession, userAnonymizer);
+  private final WsActionTester ws = new WsActionTester(new DeactivateAction(dbClient, userSession, new UserJsonWriter(userSession), userDeactivator));
 
   @Test
   public void deactivate_user_and_delete_their_related_data() {
@@ -441,6 +438,19 @@ public class DeactivateActionTest {
     assertJson(json).isSimilarTo(ws.getDef().responseExampleAsString());
   }
 
+  @Test
+  public void anonymizeUser_whenSamlAndScimUser_shouldDeleteScimMapping() {
+    createAdminUser();
+    logInAsSystemAdministrator();
+    UserDto user = db.users().insertUser();
+    db.getDbClient().scimUserDao().enableScimForUser(dbSession, user.getUuid());
+    db.commit();
+
+    deactivate(user.getLogin(), true);
+
+    assertThat(db.getDbClient().scimUserDao().findByUserUuid(dbSession, user.getUuid())).isEmpty();
+  }
+
   private void logInAsSystemAdministrator() {
     userSession.logIn().setSystemAdministrator();
   }
index 715727a4a05f31639727ff633fb7996029a4c014..92b4cb2657202fa929b2758f6a629e770e1ae353 100644 (file)
@@ -21,8 +21,8 @@ package org.sonar.server.platform.web;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
-import com.google.common.collect.Lists;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
@@ -44,6 +44,7 @@ import org.sonar.server.platform.PlatformImpl;
  */
 public class MasterServletFilter implements Filter {
 
+  private static final String SCIM_FILTER_PATH = "/api/scim/v2/";
   private static volatile MasterServletFilter instance;
   private ServletFilter[] filters;
   private FilterConfig config;
@@ -78,17 +79,29 @@ public class MasterServletFilter implements Filter {
   }
 
   public void initFilters(List<ServletFilter> filterExtensions) {
-    List<ServletFilter> filterList = Lists.newArrayList();
+    LinkedList<ServletFilter> filterList = new LinkedList<>();
     for (ServletFilter extension : filterExtensions) {
       try {
         Loggers.get(MasterServletFilter.class).info(String.format("Initializing servlet filter %s [pattern=%s]", extension, extension.doGetPattern().label()));
         extension.init(config);
-        filterList.add(extension);
+        // As for scim we need to intercept traffic to URLs with path parameters
+        // and that use is not properly handled when dealing with inclusions/exclusions of the WebServiceFilter,
+        // we need to make sure the Scim filters are invoked before the WebserviceFilter
+        if (isScimFilter(extension)) {
+          filterList.addFirst(extension);
+        } else {
+          filterList.addLast(extension);
+        }
       } catch (Exception e) {
         throw new IllegalStateException("Fail to initialize servlet filter: " + extension + ". Message: " + e.getMessage(), e);
       }
     }
-    filters = filterList.toArray(new ServletFilter[filterList.size()]);
+    filters = filterList.toArray(new ServletFilter[0]);
+  }
+
+  private static boolean isScimFilter(ServletFilter extension) {
+    return extension.doGetPattern().getInclusions().stream()
+      .anyMatch(s -> s.startsWith(SCIM_FILTER_PATH));
   }
 
   @Override
@@ -99,16 +112,17 @@ public class MasterServletFilter implements Filter {
     } else {
       String path = hsr.getRequestURI().replaceFirst(hsr.getContextPath(), "");
       GodFilterChain godChain = new GodFilterChain(chain);
-
-      for (ServletFilter filter : filters) {
-        if (filter.doGetPattern().matches(path)) {
-          godChain.addFilter(filter);
-        }
-      }
+      buildGodchain(path, godChain);
       godChain.doFilter(hsr, response);
     }
   }
 
+  private void buildGodchain(String path, GodFilterChain godChain) {
+    Arrays.stream(filters)
+      .filter(filter -> filter.doGetPattern().matches(path))
+      .forEachOrdered(godChain::addFilter);
+  }
+
   @Override
   public void destroy() {
     for (ServletFilter filter : filters) {
index 046d8117a1918e3a17a8f606c70395c1abf709e2..1d3c54d63e3222532a63e10513f02822a30071ce 100644 (file)
@@ -31,6 +31,8 @@ import javax.servlet.http.HttpServletResponse;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
 import org.sonar.api.utils.log.LogTester;
 import org.sonar.api.utils.log.LoggerLevel;
 import org.sonar.api.web.ServletFilter;
@@ -41,6 +43,7 @@ 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.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
@@ -48,7 +51,6 @@ import static org.mockito.Mockito.when;
 
 public class MasterServletFilterTest {
 
-
   @Rule
   public LogTester logTester = new LogTester();
 
@@ -108,22 +110,39 @@ public class MasterServletFilterTest {
   }
 
   @Test
-  public void should_keep_filter_ordering() throws Exception {
-    TrueFilter filter1 = new TrueFilter();
-    TrueFilter filter2 = new TrueFilter();
-
-    MasterServletFilter filters = new MasterServletFilter();
-    filters.init(mock(FilterConfig.class), asList(filter1, filter2));
-
+  public void should_add_scim_filter_first_for_scim_request() throws Exception {
+    String scimPath = "/api/scim/v2/Groups";
     HttpServletRequest request = mock(HttpServletRequest.class);
-    when(request.getRequestURI()).thenReturn("/foo/bar");
+    when(request.getRequestURI()).thenReturn(scimPath);
     when(request.getContextPath()).thenReturn("");
     ServletResponse response = mock(HttpServletResponse.class);
     FilterChain chain = mock(FilterChain.class);
+
+    ServletFilter filter1 = mockFilter(ServletFilter.class, request, response);
+    ServletFilter filter2 = mockFilter(ServletFilter.class, request, response);
+    ServletFilter filter3 = mockFilter(WebServiceFilter.class, request, response);
+    when(filter3.doGetPattern()).thenReturn(UrlPattern.builder().includes(scimPath).build());
+
+    MasterServletFilter filters = new MasterServletFilter();
+    filters.init(mock(FilterConfig.class), asList(filter3, filter1, filter2));
+
     filters.doFilter(request, response, chain);
 
-    assertThat(filter1.count).isOne();
-    assertThat(filter2.count).isEqualTo(2);
+    InOrder inOrder = Mockito.inOrder(filter1, filter2, filter3);
+    inOrder.verify(filter3).doFilter(any(), any(), any());
+    inOrder.verify(filter1).doFilter(any(), any(), any());
+    inOrder.verify(filter2).doFilter(any(), any(), any());
+  }
+
+  private ServletFilter mockFilter(Class<? extends ServletFilter> filterClazz, HttpServletRequest request, ServletResponse response) throws IOException, ServletException {
+    ServletFilter filter = mock(filterClazz);
+    when(filter.doGetPattern()).thenReturn(UrlPattern.builder().build());
+    doAnswer(invocation -> {
+      FilterChain argument = invocation.getArgument(2, FilterChain.class);
+      argument.doFilter(request, response);
+      return null;
+    }).when(filter).doFilter(any(), any(), any());
+    return filter;
   }
 
   @Test
@@ -143,26 +162,6 @@ public class MasterServletFilterTest {
     return filter;
   }
 
-  private static final class TrueFilter extends ServletFilter {
-    private static int globalCount = 0;
-    private int count = 0;
-
-    @Override
-    public void init(FilterConfig filterConfig) {
-    }
-
-    @Override
-    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
-      globalCount++;
-      count = globalCount;
-      filterChain.doFilter(servletRequest, servletResponse);
-    }
-
-    @Override
-    public void destroy() {
-    }
-  }
-
   private static class PatternFilter extends ServletFilter {
 
     private final UrlPattern urlPattern;