diff options
author | Wojtek Wajerowicz <115081248+wojciech-wajerowicz-sonarsource@users.noreply.github.com> | 2024-03-19 14:15:49 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-03-28 20:02:50 +0000 |
commit | 412f42cb112802614e541ca186d3e35006f28be9 (patch) | |
tree | 65afa0df4c797929ee01d79ec0ba6efb766b82fb /server/sonar-webserver-common | |
parent | 1398b6005bc206cd64bf570b73ee18092fe88a23 (diff) | |
download | sonarqube-412f42cb112802614e541ca186d3e35006f28be9.tar.gz sonarqube-412f42cb112802614e541ca186d3e35006f28be9.zip |
SONAR-21819 Extract logic reusable between legacy and v2 endpoints.
Diffstat (limited to 'server/sonar-webserver-common')
47 files changed, 6048 insertions, 0 deletions
diff --git a/server/sonar-webserver-common/build.gradle b/server/sonar-webserver-common/build.gradle index d12d353e29b..89ce2803657 100644 --- a/server/sonar-webserver-common/build.gradle +++ b/server/sonar-webserver-common/build.gradle @@ -26,11 +26,15 @@ dependencies { testImplementation 'org.assertj:assertj-core' testImplementation 'org.junit.jupiter:junit-jupiter-api' testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' testImplementation project(':sonar-testing-harness') testImplementation testFixtures(project(':server:sonar-db-dao')) testImplementation testFixtures(project(':server:sonar-server-common')) testImplementation testFixtures(project(':server:sonar-webserver-api')) + testImplementation testFixtures(project(':server:sonar-webserver-auth')) + testImplementation testFixtures(project(':server:sonar-webserver-es')) + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' testRuntimeOnly 'org.junit.vintage:junit-vintage-engine' diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/component/ComponentUpdaterIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/component/ComponentUpdaterIT.java new file mode 100644 index 00000000000..30a70a126ef --- /dev/null +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/component/ComponentUpdaterIT.java @@ -0,0 +1,545 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.component; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.resources.Scopes; +import org.sonar.api.utils.System2; +import org.sonar.api.web.UserRole; +import org.sonar.core.util.SequenceUuidFactory; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.db.audit.AuditPersister; +import org.sonar.db.component.BranchDto; +import org.sonar.db.component.BranchType; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.component.ResourceTypesRule; +import org.sonar.db.project.CreationMethod; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.common.permission.GroupPermissionChanger; +import org.sonar.server.common.permission.PermissionTemplateService; +import org.sonar.server.common.permission.PermissionUpdater; +import org.sonar.server.common.permission.UserPermissionChange; +import org.sonar.server.common.permission.UserPermissionChanger; +import org.sonar.server.component.ComponentCreationData; +import org.sonar.server.es.EsTester; +import org.sonar.server.es.Indexers; +import org.sonar.server.es.IndexersImpl; +import org.sonar.server.es.TestIndexers; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.favorite.FavoriteUpdater; +import org.sonar.server.l18n.I18nRule; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.permission.PermissionServiceImpl; +import org.sonar.server.permission.index.FooIndexDefinition; +import org.sonar.server.permission.index.PermissionIndexer; +import org.sonar.server.project.DefaultBranchNameResolver; + +import static java.util.stream.IntStream.rangeClosed; +import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; +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.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.api.resources.Qualifiers.APP; +import static org.sonar.api.resources.Qualifiers.PROJECT; +import static org.sonar.api.resources.Qualifiers.VIEW; +import static org.sonar.db.component.BranchDto.DEFAULT_MAIN_BRANCH_NAME; + +public class ComponentUpdaterIT { + + private static final String DEFAULT_PROJECT_KEY = "project-key"; + private static final String DEFAULT_PROJECT_NAME = "project-name"; + private static final NewComponent DEFAULT_COMPONENT = NewComponent.newComponentBuilder() + .setKey(DEFAULT_PROJECT_KEY) + .setName(DEFAULT_PROJECT_NAME) + .build(); + private static final NewComponent PRIVATE_COMPONENT = NewComponent.newComponentBuilder() + .setKey(DEFAULT_PROJECT_KEY) + .setName(DEFAULT_PROJECT_NAME) + .setPrivate(true) + .build(); + private static final String DEFAULT_USER_UUID = "user-uuid"; + public static final String DEFAULT_USER_LOGIN = "user-login"; + + private final System2 system2 = System2.INSTANCE; + + private final AuditPersister auditPersister = mock(); + + @Rule + public final DbTester db = DbTester.create(system2, auditPersister); + @Rule + public final I18nRule i18n = new I18nRule().put("qualifier.TRK", "Project"); + + private final TestIndexers projectIndexers = new TestIndexers(); + private final PermissionTemplateService permissionTemplateService = mock(PermissionTemplateService.class); + private final DefaultBranchNameResolver defaultBranchNameResolver = mock(DefaultBranchNameResolver.class); + public EsTester es = EsTester.createCustom(new FooIndexDefinition()); + private final PermissionUpdater<UserPermissionChange> userPermissionUpdater = new PermissionUpdater( + new IndexersImpl(new PermissionIndexer(db.getDbClient(), es.client())), + Set.of(new UserPermissionChanger(db.getDbClient(), new SequenceUuidFactory()), + new GroupPermissionChanger(db.getDbClient(), new SequenceUuidFactory()))); + private final PermissionService permissionService = new PermissionServiceImpl(new ResourceTypesRule().setRootQualifiers(Qualifiers.PROJECT)); + + private final ComponentUpdater underTest = new ComponentUpdater(db.getDbClient(), i18n, system2, + permissionTemplateService, + new FavoriteUpdater(db.getDbClient()), + projectIndexers, new SequenceUuidFactory(), defaultBranchNameResolver, userPermissionUpdater, permissionService); + + @Before + public void before() { + when(defaultBranchNameResolver.getEffectiveMainBranchName()).thenReturn(DEFAULT_MAIN_BRANCH_NAME); + } + + @Test + public void persist_and_index_when_creating_project() { + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(PRIVATE_COMPONENT) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ComponentCreationData returned = underTest.create(db.getSession(), creationParameters); + + ComponentDto loaded = db.getDbClient().componentDao().selectOrFailByUuid(db.getSession(), returned.mainBranchComponent().uuid()); + assertThat(loaded.getKey()).isEqualTo(DEFAULT_PROJECT_KEY); + assertThat(loaded.name()).isEqualTo(DEFAULT_PROJECT_NAME); + assertThat(loaded.longName()).isEqualTo(DEFAULT_PROJECT_NAME); + assertThat(loaded.qualifier()).isEqualTo(Qualifiers.PROJECT); + assertThat(loaded.scope()).isEqualTo(Scopes.PROJECT); + assertThat(loaded.uuid()).isNotNull(); + assertThat(loaded.branchUuid()).isEqualTo(loaded.uuid()); + assertThat(loaded.isPrivate()).isEqualTo(PRIVATE_COMPONENT.isPrivate()); + assertThat(loaded.getCreatedAt()).isNotNull(); + assertThat(db.getDbClient().componentDao().selectByKey(db.getSession(), DEFAULT_PROJECT_KEY)).isPresent(); + + assertThat(projectIndexers.hasBeenCalledForEntity(returned.projectDto().getUuid(), Indexers.EntityEvent.CREATION)).isTrue(); + + Optional<BranchDto> branch = db.getDbClient().branchDao().selectByUuid(db.getSession(), returned.mainBranchComponent().uuid()); + assertThat(branch).isPresent(); + assertThat(branch.get().getKey()).isEqualTo(DEFAULT_MAIN_BRANCH_NAME); + assertThat(branch.get().getMergeBranchUuid()).isNull(); + assertThat(branch.get().getBranchType()).isEqualTo(BranchType.BRANCH); + assertThat(branch.get().getUuid()).isEqualTo(returned.mainBranchComponent().uuid()); + assertThat(branch.get().getProjectUuid()).isEqualTo(returned.projectDto().getUuid()); + } + + @Test + public void create_project_with_main_branch_global_property() { + when(defaultBranchNameResolver.getEffectiveMainBranchName()).thenReturn("main-branch-global"); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(PRIVATE_COMPONENT) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + ComponentDto returned = underTest.create(db.getSession(), creationParameters).mainBranchComponent(); + + Optional<BranchDto> branch = db.getDbClient().branchDao().selectByUuid(db.getSession(), returned.branchUuid()); + assertThat(branch).get().extracting(BranchDto::getBranchKey).isEqualTo("main-branch-global"); + } + + @Test + public void persist_private_flag_true_when_creating_project() { + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(PRIVATE_COMPONENT) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ComponentDto returned = underTest.create(db.getSession(), creationParameters).mainBranchComponent(); + ComponentDto loaded = db.getDbClient().componentDao().selectOrFailByUuid(db.getSession(), returned.uuid()); + assertThat(loaded.isPrivate()).isEqualTo(PRIVATE_COMPONENT.isPrivate()); + } + + @Test + public void persist_private_flag_false_when_creating_project() { + NewComponent project = NewComponent.newComponentBuilder() + .setKey(DEFAULT_PROJECT_KEY) + .setName(DEFAULT_PROJECT_NAME) + .setPrivate(false) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(project) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ComponentDto returned = underTest.create(db.getSession(), creationParameters).mainBranchComponent(); + ComponentDto loaded = db.getDbClient().componentDao().selectOrFailByUuid(db.getSession(), returned.uuid()); + assertThat(loaded.isPrivate()).isEqualTo(project.isPrivate()); + } + + @Test + public void create_view() { + NewComponent view = NewComponent.newComponentBuilder() + .setKey("view-key") + .setName("view-name") + .setQualifier(VIEW) + .build(); + + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(view) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ComponentDto returned = underTest.create(db.getSession(), creationParameters).mainBranchComponent(); + + ComponentDto loaded = db.getDbClient().componentDao().selectOrFailByUuid(db.getSession(), returned.uuid()); + assertThat(loaded.getKey()).isEqualTo("view-key"); + assertThat(loaded.name()).isEqualTo("view-name"); + assertThat(loaded.qualifier()).isEqualTo("VW"); + assertThat(projectIndexers.hasBeenCalledForEntity(loaded.uuid(), Indexers.EntityEvent.CREATION)).isTrue(); + Optional<BranchDto> branch = db.getDbClient().branchDao().selectByUuid(db.getSession(), returned.uuid()); + assertThat(branch).isNotPresent(); + } + + @Test + public void create_application() { + NewComponent application = NewComponent.newComponentBuilder() + .setKey("app-key") + .setName("app-name") + .setQualifier(APP) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(application) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ComponentCreationData returned = underTest.create(db.getSession(), creationParameters); + + ProjectDto loaded = db.getDbClient().projectDao().selectByUuid(db.getSession(), returned.projectDto().getUuid()).get(); + assertThat(loaded.getKey()).isEqualTo("app-key"); + assertThat(loaded.getName()).isEqualTo("app-name"); + assertThat(loaded.getQualifier()).isEqualTo("APP"); + assertThat(projectIndexers.hasBeenCalledForEntity(loaded.getUuid(), Indexers.EntityEvent.CREATION)).isTrue(); + Optional<BranchDto> branch = db.getDbClient().branchDao().selectByUuid(db.getSession(), returned.mainBranchComponent().uuid()); + assertThat(branch).isPresent(); + assertThat(branch.get().getKey()).isEqualTo(DEFAULT_MAIN_BRANCH_NAME); + assertThat(branch.get().getMergeBranchUuid()).isNull(); + assertThat(branch.get().getBranchType()).isEqualTo(BranchType.BRANCH); + assertThat(branch.get().getUuid()).isEqualTo(returned.mainBranchComponent().uuid()); + assertThat(branch.get().getProjectUuid()).isEqualTo(returned.projectDto().getUuid()); + } + + @Test + public void apply_default_permission_template() { + ComponentCreationParameters componentCreationParameters = ComponentCreationParameters.builder() + .newComponent(DEFAULT_COMPONENT) + .userLogin(DEFAULT_USER_LOGIN) + .userUuid(DEFAULT_USER_UUID) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + ProjectDto dto = underTest.create(db.getSession(), componentCreationParameters).projectDto(); + + verify(permissionTemplateService).applyDefaultToNewComponent(db.getSession(), dto, DEFAULT_USER_UUID); + } + + @Test + public void add_project_to_user_favorites_if_project_creator_is_defined_in_permission_template() { + UserDto userDto = db.users().insertUser(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(DEFAULT_COMPONENT) + .userLogin(userDto.getLogin()) + .userUuid(userDto.getUuid()) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + when(permissionTemplateService.hasDefaultTemplateWithPermissionOnProjectCreator(any(DbSession.class), any(ProjectDto.class))) + .thenReturn(true); + + ProjectDto dto = underTest.create(db.getSession(), creationParameters).projectDto(); + + assertThat(db.favorites().hasFavorite(dto, userDto.getUuid())).isTrue(); + } + + @Test + public void do_not_add_project_to_user_favorites_if_project_creator_is_defined_in_permission_template_and_already_100_favorites() { + UserDto user = db.users().insertUser(); + rangeClosed(1, 100).forEach(i -> db.favorites().add(db.components().insertPrivateProject().getProjectDto(), user.getUuid(), user.getLogin())); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(DEFAULT_COMPONENT) + .userLogin(user.getLogin()) + .userUuid(user.getUuid()) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + when(permissionTemplateService.hasDefaultTemplateWithPermissionOnProjectCreator(eq(db.getSession()), any(ProjectDto.class))) + .thenReturn(true); + + ProjectDto dto = underTest.create(db.getSession(), creationParameters).projectDto(); + + assertThat(db.favorites().hasFavorite(dto, user.getUuid())).isFalse(); + } + + @Test + public void does_not_add_project_to_favorite_when_anonymously_created() { + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(DEFAULT_COMPONENT) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ProjectDto projectDto = underTest.create(db.getSession(), creationParameters).projectDto(); + + assertThat(db.favorites().hasNoFavorite(projectDto)).isTrue(); + } + + @Test + public void fail_when_project_key_already_exists() { + ComponentDto existing = db.components().insertPrivateProject().getMainBranchComponent(); + DbSession session = db.getSession(); + + NewComponent project = NewComponent.newComponentBuilder() + .setKey(existing.getKey()) + .setName(DEFAULT_PROJECT_NAME) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(project) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + assertThatThrownBy(() -> underTest.create(session, creationParameters)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s\"", existing.getKey(), existing.getKey()); + } + + @Test + public void fail_when_key_has_bad_format() { + DbSession session = db.getSession(); + NewComponent project = NewComponent.newComponentBuilder() + .setKey("1234") + .setName(DEFAULT_PROJECT_NAME) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(project) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + assertThatThrownBy(() -> underTest.create(session, creationParameters)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Malformed key for Project: '1234'"); + } + + @Test + public void fail_when_key_contains_percent_character() { + DbSession session = db.getSession(); + NewComponent project = NewComponent.newComponentBuilder() + .setKey("roject%Key") + .setName(DEFAULT_PROJECT_NAME) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(project) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + assertThatThrownBy(() -> underTest.create(session, creationParameters)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Malformed key for Project: 'roject%Key'"); + } + + @Test + public void create_shouldFail_whenCreatingProjectWithExistingKeyButDifferentCase() { + createComponent_shouldFail_whenCreatingComponentWithExistingKeyButDifferentCase(PROJECT); + } + + @Test + public void create_shouldFail_whenCreatingPortfolioWithExistingKeyButDifferentCase() { + createComponent_shouldFail_whenCreatingComponentWithExistingKeyButDifferentCase(VIEW); + } + + @Test + public void create_shouldFail_whenCreatingApplicationWithExistingKeyButDifferentCase() { + createComponent_shouldFail_whenCreatingComponentWithExistingKeyButDifferentCase(APP); + } + + private void createComponent_shouldFail_whenCreatingComponentWithExistingKeyButDifferentCase(String qualifier) { + String existingKey = randomAlphabetic(5).toUpperCase(); + db.components().insertPrivateProject(component -> component.setKey(existingKey)); + String newKey = existingKey.toLowerCase(); + + NewComponent project = NewComponent.newComponentBuilder() + .setKey(newKey) + .setName(DEFAULT_PROJECT_NAME) + .setQualifier(qualifier) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(project) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + DbSession dbSession = db.getSession(); + assertThatThrownBy(() -> underTest.create(dbSession, creationParameters)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s\"", newKey, existingKey); + } + + @Test + public void createComponent_shouldFail_whenCreatingComponentWithMultipleExistingKeyButDifferentCase() { + String existingKey = randomAlphabetic(5).toUpperCase(); + String existingKeyLowerCase = existingKey.toLowerCase(); + db.components().insertPrivateProject(component -> component.setKey(existingKey)); + db.components().insertPrivateProject(component -> component.setKey(existingKeyLowerCase)); + String newKey = StringUtils.capitalize(existingKeyLowerCase); + + NewComponent project = NewComponent.newComponentBuilder() + .setKey(newKey) + .setName(DEFAULT_PROJECT_NAME) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(project) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + DbSession dbSession = db.getSession(); + assertThatThrownBy(() -> underTest.create(dbSession, creationParameters)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s, %s\"", newKey, existingKey, existingKeyLowerCase); + } + + @Test + public void createComponent_shouldFail_whenCreatingComponentWithMultipleExistingPortfolioKeysButDifferentCase() { + String existingKey = randomAlphabetic(5).toUpperCase(); + String existingKeyLowerCase = existingKey.toLowerCase(); + db.components().insertPrivatePortfolio(portfolio -> portfolio.setKey(existingKey)); + db.components().insertPrivatePortfolio(portfolio -> portfolio.setKey(existingKeyLowerCase)); + String newKey = StringUtils.capitalize(existingKeyLowerCase); + + NewComponent project = NewComponent.newComponentBuilder() + .setKey(newKey) + .setName(DEFAULT_PROJECT_NAME) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(project) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + DbSession dbSession = db.getSession(); + assertThatThrownBy(() -> underTest.create(dbSession, creationParameters)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Could not create Project with key: \"%s\". A similar key already exists: \"%s, %s\"", newKey, existingKey, existingKeyLowerCase); + } + + @Test + public void create_createsComponentWithMasterBranchName() { + String componentNameAndKey = "createApplicationOrPortfolio"; + NewComponent app = NewComponent.newComponentBuilder() + .setKey(componentNameAndKey) + .setName(componentNameAndKey) + .setQualifier("APP") + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(app) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + ComponentDto appDto = underTest.create(db.getSession(), creationParameters).mainBranchComponent(); + + Optional<BranchDto> branch = db.getDbClient().branchDao().selectByUuid(db.getSession(), appDto.branchUuid()); + assertThat(branch).isPresent(); + assertThat(branch.get().getBranchKey()).isEqualTo(DEFAULT_MAIN_BRANCH_NAME); + } + + @Test + public void createWithoutCommit_whenProjectIsManaged_doesntApplyPermissionTemplate() { + UserDto userDto = db.users().insertUser(); + ComponentCreationParameters componentCreationParameters = ComponentCreationParameters.builder() + .newComponent(DEFAULT_COMPONENT) + .userLogin(userDto.getLogin()) + .userUuid(userDto.getUuid()) + .mainBranchName(null) + .isManaged(true) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + underTest.createWithoutCommit(db.getSession(), componentCreationParameters); + + verify(permissionTemplateService, never()).applyDefaultToNewComponent(any(), any(), any()); + } + + @Test + public void createWithoutCommit_whenInsertingPortfolio_shouldOnlyAddOneEntryToAuditLogs() { + String portfolioKey = "portfolio"; + NewComponent portfolio = NewComponent.newComponentBuilder() + .setKey(portfolioKey) + .setName(portfolioKey) + .setQualifier(VIEW) + .build(); + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(portfolio) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + + underTest.createWithoutCommit(db.getSession(), creationParameters); + db.commit(); + + verify(auditPersister, times(1)).addComponent(argThat(d -> d.equals(db.getSession())), + argThat(newValue -> newValue.getComponentKey().equals(portfolioKey))); + } + + @Test + public void createWithoutCommit_whenProjectIsManagedAndPrivate_applyPublicPermissionsToCreator() { + UserDto userDto = db.users().insertUser(); + NewComponent newComponent = NewComponent.newComponentBuilder() + .setKey(DEFAULT_PROJECT_KEY) + .setName(DEFAULT_PROJECT_NAME) + .setPrivate(true) + .build(); + + DbSession session = db.getSession(); + + ComponentCreationParameters componentCreationParameters = ComponentCreationParameters.builder() + .newComponent(PRIVATE_COMPONENT) + .userLogin(userDto.getLogin()) + .userUuid(userDto.getUuid()) + .mainBranchName(null) + .isManaged(true) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ComponentCreationData componentCreationData = underTest.createWithoutCommit(session, componentCreationParameters); + + List<String> permissions = db.getDbClient().userPermissionDao().selectEntityPermissionsOfUser(session, userDto.getUuid(), componentCreationData.projectDto().getUuid()); + assertThat(permissions) + .containsExactlyInAnyOrder(UserRole.USER, UserRole.CODEVIEWER); + } + + @Test + public void create_whenCreationMethodIsLocalApi_persistsIt() { + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(DEFAULT_COMPONENT) + .creationMethod(CreationMethod.LOCAL_API) + .build(); + ProjectDto projectDto = underTest.create(db.getSession(), creationParameters).projectDto(); + assertThat(projectDto.getCreationMethod()).isEqualTo(CreationMethod.LOCAL_API); + } + + @Test + public void create_whenCreationMethodIsAlmImportBrowser_persistsIt() { + ComponentCreationParameters creationParameters = ComponentCreationParameters.builder() + .newComponent(DEFAULT_COMPONENT) + .creationMethod(CreationMethod.ALM_IMPORT_BROWSER) + .build(); + ProjectDto projectDto = underTest.create(db.getSession(), creationParameters).projectDto(); + assertThat(projectDto.getCreationMethod()).isEqualTo(CreationMethod.ALM_IMPORT_BROWSER); + } +} diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/DefaultTemplatesResolverImplIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/DefaultTemplatesResolverImplIT.java new file mode 100644 index 00000000000..2a3716ab1af --- /dev/null +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/DefaultTemplatesResolverImplIT.java @@ -0,0 +1,110 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.utils.System2; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.db.component.ResourceTypesRule; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.sonar.api.resources.Qualifiers.APP; +import static org.sonar.api.resources.Qualifiers.PROJECT; +import static org.sonar.api.resources.Qualifiers.VIEW; + +public class DefaultTemplatesResolverImplIT { + + @Rule + public DbTester db = DbTester.create(System2.INSTANCE); + + private ResourceTypesRule resourceTypesWithPortfoliosInstalled = new ResourceTypesRule().setRootQualifiers(PROJECT, APP, VIEW); + private ResourceTypesRule resourceTypesWithApplicationInstalled = new ResourceTypesRule().setRootQualifiers(PROJECT, APP); + private ResourceTypesRule resourceTypes = new ResourceTypesRule().setRootQualifiers(PROJECT); + + private DefaultTemplatesResolverImpl underTestWithPortfoliosInstalled = new DefaultTemplatesResolverImpl(db.getDbClient(), resourceTypesWithPortfoliosInstalled); + private DefaultTemplatesResolverImpl underTestWithApplicationInstalled = new DefaultTemplatesResolverImpl(db.getDbClient(), resourceTypesWithApplicationInstalled); + private DefaultTemplatesResolverImpl underTest = new DefaultTemplatesResolverImpl(db.getDbClient(), resourceTypes); + + @Test + public void get_default_templates_when_portfolio_not_installed() { + db.permissionTemplates().setDefaultTemplates("prj", null, null); + + assertThat(underTest.resolve(db.getSession()).getProject()).contains("prj"); + assertThat(underTest.resolve(db.getSession()).getApplication()).isEmpty(); + assertThat(underTest.resolve(db.getSession()).getPortfolio()).isEmpty(); + } + + @Test + public void get_default_templates_always_return_project_template_even_when_all_templates_are_defined_but_portfolio_not_installed() { + db.permissionTemplates().setDefaultTemplates("prj", "app", "port"); + + assertThat(underTest.resolve(db.getSession()).getProject()).contains("prj"); + assertThat(underTest.resolve(db.getSession()).getApplication()).isEmpty(); + assertThat(underTest.resolve(db.getSession()).getPortfolio()).isEmpty(); + } + + @Test + public void get_default_templates_always_return_project_template_when_only_project_template_and_portfolio_is_installed_() { + db.permissionTemplates().setDefaultTemplates("prj", null, null); + + assertThat(underTestWithPortfoliosInstalled.resolve(db.getSession()).getProject()).contains("prj"); + assertThat(underTestWithPortfoliosInstalled.resolve(db.getSession()).getApplication()).contains("prj"); + assertThat(underTestWithPortfoliosInstalled.resolve(db.getSession()).getPortfolio()).contains("prj"); + } + + @Test + public void get_default_templates_for_all_components_when_portfolio_is_installed() { + db.permissionTemplates().setDefaultTemplates("prj", "app", "port"); + + assertThat(underTestWithPortfoliosInstalled.resolve(db.getSession()).getProject()).contains("prj"); + assertThat(underTestWithPortfoliosInstalled.resolve(db.getSession()).getApplication()).contains("app"); + assertThat(underTestWithPortfoliosInstalled.resolve(db.getSession()).getPortfolio()).contains("port"); + } + + @Test + public void get_default_templates_always_return_project_template_when_only_project_template_and_application_is_installed_() { + db.permissionTemplates().setDefaultTemplates("prj", null, null); + + assertThat(underTestWithApplicationInstalled.resolve(db.getSession()).getProject()).contains("prj"); + assertThat(underTestWithApplicationInstalled.resolve(db.getSession()).getApplication()).contains("prj"); + assertThat(underTestWithApplicationInstalled.resolve(db.getSession()).getPortfolio()).isEmpty(); + } + + @Test + public void get_default_templates_for_all_components_when_application_is_installed() { + db.permissionTemplates().setDefaultTemplates("prj", "app", null); + + assertThat(underTestWithApplicationInstalled.resolve(db.getSession()).getProject()).contains("prj"); + assertThat(underTestWithApplicationInstalled.resolve(db.getSession()).getApplication()).contains("app"); + assertThat(underTestWithApplicationInstalled.resolve(db.getSession()).getPortfolio()).isEmpty(); + } + + @Test + public void fail_when_default_template_for_project_is_missing() { + DbSession session = db.getSession(); + assertThatThrownBy(() -> underTestWithPortfoliosInstalled.resolve(session)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Default template for project is missing"); + } + +} diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/GroupPermissionChangerIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/GroupPermissionChangerIT.java new file mode 100644 index 00000000000..03e760bea6b --- /dev/null +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/GroupPermissionChangerIT.java @@ -0,0 +1,408 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.resources.ResourceTypes; +import org.sonar.api.utils.System2; +import org.sonar.api.web.UserRole; +import org.sonar.core.util.SequenceUuidFactory; +import org.sonar.core.util.Uuids; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.db.component.ResourceTypesRule; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.permission.GroupPermissionDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.common.permission.GroupPermissionChange; +import org.sonar.server.common.permission.GroupPermissionChanger; +import org.sonar.server.common.permission.Operation; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.permission.PermissionServiceImpl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; +import static org.sonar.db.permission.GlobalPermission.ADMINISTER; +import static org.sonar.db.permission.GlobalPermission.ADMINISTER_QUALITY_GATES; +import static org.sonar.db.permission.GlobalPermission.ADMINISTER_QUALITY_PROFILES; +import static org.sonar.db.permission.GlobalPermission.PROVISION_PROJECTS; + +public class GroupPermissionChangerIT { + + @Rule + public DbTester db = DbTester.create(System2.INSTANCE); + + private final ResourceTypes resourceTypes = new ResourceTypesRule().setRootQualifiers(Qualifiers.PROJECT); + private final PermissionService permissionService = new PermissionServiceImpl(resourceTypes); + private final GroupPermissionChanger underTest = new GroupPermissionChanger(db.getDbClient(), new SequenceUuidFactory()); + private GroupDto group; + + private ProjectDto privateProject; + private ProjectDto publicProject; + + @Before + public void setUp() { + group = db.users().insertGroup("a-group"); + privateProject = db.components().insertPrivateProject().getProjectDto(); + publicProject = db.components().insertPublicProject().getProjectDto(); + } + + @Test + public void apply_adds_global_permission_to_group() { + apply(new GroupPermissionChange(Operation.ADD, ADMINISTER_QUALITY_PROFILES.getKey(), null, group, permissionService)); + + assertThat(db.users().selectGroupPermissions(group, null)).containsOnly(ADMINISTER_QUALITY_PROFILES.getKey()); + } + + @Test + public void apply_adds_global_permission_to_group_AnyOne() { + apply(new GroupPermissionChange(Operation.ADD, ADMINISTER_QUALITY_PROFILES.getKey(), null, null, permissionService)); + + assertThat(db.users().selectAnyonePermissions(null)).containsOnly(ADMINISTER_QUALITY_PROFILES.getKey()); + } + + @Test + public void apply_fails_with_BadRequestException_when_adding_any_permission_to_group_AnyOne_on_private_project() { + permissionService.getAllProjectPermissions() + .forEach(perm -> { + GroupPermissionChange change = new GroupPermissionChange(Operation.ADD, perm, privateProject, null, permissionService); + try { + apply(change); + fail("a BadRequestException should have been thrown"); + } catch (BadRequestException e) { + assertThat(e).hasMessage("No permission can be granted to Anyone on a private component"); + } + }); + } + + @Test + public void apply_has_no_effect_when_removing_any_permission_to_group_AnyOne_on_private_project() { + permissionService.getAllProjectPermissions() + .forEach(this::unsafeInsertProjectPermissionOnAnyone); + + permissionService.getAllProjectPermissions() + .forEach(perm -> { + apply(new GroupPermissionChange(Operation.REMOVE, perm, privateProject, null, permissionService)); + + assertThat(db.users().selectAnyonePermissions(privateProject.getUuid())).contains(perm); + }); + } + + @Test + public void apply_adds_permission_USER_to_group_on_private_project() { + applyAddsPermissionToGroupOnPrivateProject(UserRole.USER); + } + + @Test + public void apply_adds_permission_CODEVIEWER_to_group_on_private_project() { + applyAddsPermissionToGroupOnPrivateProject(UserRole.CODEVIEWER); + } + + @Test + public void apply_adds_permission_ADMIN_to_group_on_private_project() { + applyAddsPermissionToGroupOnPrivateProject(UserRole.ADMIN); + } + + @Test + public void apply_adds_permission_ISSUE_ADMIN_to_group_on_private_project() { + applyAddsPermissionToGroupOnPrivateProject(UserRole.ISSUE_ADMIN); + } + + @Test + public void apply_adds_permission_SCAN_EXECUTION_to_group_on_private_project() { + applyAddsPermissionToGroupOnPrivateProject(GlobalPermission.SCAN.getKey()); + } + + private void applyAddsPermissionToGroupOnPrivateProject(String permission) { + + apply(new GroupPermissionChange(Operation.ADD, permission, privateProject, group, permissionService)); + + assertThat(db.users().selectGroupPermissions(group, null)).isEmpty(); + assertThat(db.users().selectGroupPermissions(group, privateProject)).containsOnly(permission); + } + + @Test + public void apply_removes_permission_USER_from_group_on_private_project() { + applyRemovesPermissionFromGroupOnPrivateProject(UserRole.USER); + } + + @Test + public void apply_removes_permission_CODEVIEWER_from_group_on_private_project() { + applyRemovesPermissionFromGroupOnPrivateProject(UserRole.CODEVIEWER); + } + + @Test + public void apply_removes_permission_ADMIN_from_on_private_project() { + applyRemovesPermissionFromGroupOnPrivateProject(UserRole.ADMIN); + } + + @Test + public void apply_removes_permission_ISSUE_ADMIN_from_on_private_project() { + applyRemovesPermissionFromGroupOnPrivateProject(UserRole.ISSUE_ADMIN); + } + + @Test + public void apply_removes_permission_SCAN_EXECUTION_from_on_private_project() { + applyRemovesPermissionFromGroupOnPrivateProject(GlobalPermission.SCAN.getKey()); + } + + private void applyRemovesPermissionFromGroupOnPrivateProject(String permission) { + db.users().insertEntityPermissionOnGroup(group, permission, privateProject); + + apply(new GroupPermissionChange(Operation.ADD, permission, privateProject, group, permissionService), permission); + + assertThat(db.users().selectGroupPermissions(group, privateProject)).containsOnly(permission); + } + + @Test + public void apply_has_no_effect_when_adding_USER_permission_to_group_AnyOne_on_a_public_project() { + apply(new GroupPermissionChange(Operation.ADD, UserRole.USER, publicProject, null, permissionService)); + + assertThat(db.users().selectAnyonePermissions(publicProject.getUuid())).isEmpty(); + } + + @Test + public void apply_has_no_effect_when_adding_CODEVIEWER_permission_to_group_AnyOne_on_a_public_project() { + apply(new GroupPermissionChange(Operation.ADD, UserRole.CODEVIEWER, publicProject, null, permissionService)); + + assertThat(db.users().selectAnyonePermissions(publicProject.getUuid())).isEmpty(); + } + + @Test + public void apply_fails_with_BadRequestException_when_adding_permission_ADMIN_to_group_AnyOne_on_a_public_project() { + GroupPermissionChange change = new GroupPermissionChange(Operation.ADD, UserRole.ADMIN, publicProject, null, permissionService); + assertThatThrownBy(() -> apply(change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("It is not possible to add the 'admin' permission to group 'Anyone'."); + } + + @Test + public void apply_adds_permission_ISSUE_ADMIN_to_group_AnyOne_on_a_public_project() { + apply(new GroupPermissionChange(Operation.ADD, UserRole.ISSUE_ADMIN, publicProject, null, permissionService)); + + assertThat(db.users().selectAnyonePermissions(publicProject.getUuid())).containsOnly(UserRole.ISSUE_ADMIN); + } + + @Test + public void apply_adds_permission_SCAN_EXECUTION_to_group_AnyOne_on_a_public_project() { + apply(new GroupPermissionChange(Operation.ADD, GlobalPermission.SCAN.getKey(), publicProject, null, permissionService)); + + assertThat(db.users().selectAnyonePermissions(publicProject.getUuid())).containsOnly(GlobalPermission.SCAN.getKey()); + } + + @Test + public void apply_fails_with_BadRequestException_when_removing_USER_permission_from_group_AnyOne_on_a_public_project() { + GroupPermissionChange change = new GroupPermissionChange(Operation.REMOVE, UserRole.USER, publicProject, null, permissionService); + assertThatThrownBy(() -> apply(change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Permission user can't be removed from a public component"); + } + + @Test + public void apply_fails_with_BadRequestException_when_removing_CODEVIEWER_permission_from_group_AnyOne_on_a_public_project() { + GroupPermissionChange change = new GroupPermissionChange(Operation.REMOVE, UserRole.CODEVIEWER, publicProject, null, permissionService); + assertThatThrownBy(() -> apply(change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Permission codeviewer can't be removed from a public component"); + } + + @Test + public void apply_removes_ADMIN_permission_from_group_AnyOne_on_a_public_project() { + applyRemovesPermissionFromGroupAnyOneOnAPublicProject(UserRole.ADMIN); + } + + @Test + public void apply_removes_ISSUE_ADMIN_permission_from_group_AnyOne_on_a_public_project() { + applyRemovesPermissionFromGroupAnyOneOnAPublicProject(UserRole.ISSUE_ADMIN); + } + + @Test + public void apply_removes_SCAN_EXECUTION_permission_from_group_AnyOne_on_a_public_project() { + applyRemovesPermissionFromGroupAnyOneOnAPublicProject(GlobalPermission.SCAN.getKey()); + } + + private void applyRemovesPermissionFromGroupAnyOneOnAPublicProject(String permission) { + db.users().insertEntityPermissionOnAnyone(permission, publicProject); + + apply(new GroupPermissionChange(Operation.REMOVE, permission, publicProject, null, permissionService), permission); + + assertThat(db.users().selectAnyonePermissions(publicProject.getUuid())).isEmpty(); + } + + @Test + public void apply_fails_with_BadRequestException_when_removing_USER_permission_from_a_group_on_a_public_project() { + GroupPermissionChange change = new GroupPermissionChange(Operation.REMOVE, UserRole.USER, publicProject, group, permissionService); + assertThatThrownBy(() -> apply(change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Permission user can't be removed from a public component"); + } + + @Test + public void apply_fails_with_BadRequestException_when_removing_CODEVIEWER_permission_from_a_group_on_a_public_project() { + GroupPermissionChange change = new GroupPermissionChange(Operation.REMOVE, UserRole.CODEVIEWER, publicProject, group, permissionService); + assertThatThrownBy(() -> apply(change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Permission codeviewer can't be removed from a public component"); + } + + @Test + public void add_permission_to_anyone() { + apply(new GroupPermissionChange(Operation.ADD, ADMINISTER_QUALITY_PROFILES.getKey(), null, null, permissionService)); + + assertThat(db.users().selectGroupPermissions(group, null)).isEmpty(); + assertThat(db.users().selectAnyonePermissions(null)).containsOnly(ADMINISTER_QUALITY_PROFILES.getKey()); + } + + @Test + public void do_nothing_when_adding_permission_that_already_exists() { + db.users().insertPermissionOnGroup(group, ADMINISTER_QUALITY_GATES); + + apply(new GroupPermissionChange(Operation.ADD, ADMINISTER_QUALITY_GATES.getKey(), null, group, permissionService), ADMINISTER_QUALITY_GATES.getKey()); + + assertThat(db.users().selectGroupPermissions(group, null)).containsExactly(ADMINISTER_QUALITY_GATES.getKey()); + } + + @Test + public void fail_to_add_global_permission_but_SCAN_and_ADMIN_on_private_project() { + permissionService.getGlobalPermissions().stream() + .map(GlobalPermission::getKey) + .filter(perm -> !UserRole.ADMIN.equals(perm) && !GlobalPermission.SCAN.getKey().equals(perm)) + .forEach(perm -> { + try { + new GroupPermissionChange(Operation.ADD, perm, privateProject, group, permissionService); + fail("a BadRequestException should have been thrown for permission " + perm); + } catch (BadRequestException e) { + assertThat(e).hasMessage("Invalid project permission '" + perm + + "'. Valid values are [" + StringUtils.join(permissionService.getAllProjectPermissions(), ", ") + "]"); + } + }); + } + + @Test + public void fail_to_add_global_permission_but_SCAN_and_ADMIN_on_public_project() { + permissionService.getGlobalPermissions().stream() + .map(GlobalPermission::getKey) + .filter(perm -> !UserRole.ADMIN.equals(perm) && !GlobalPermission.SCAN.getKey().equals(perm)) + .forEach(perm -> { + try { + new GroupPermissionChange(Operation.ADD, perm, publicProject, group, permissionService); + fail("a BadRequestException should have been thrown for permission " + perm); + } catch (BadRequestException e) { + assertThat(e).hasMessage("Invalid project permission '" + perm + + "'. Valid values are [" + StringUtils.join(permissionService.getAllProjectPermissions(), ", ") + "]"); + } + }); + } + + @Test + public void fail_to_add_project_permission_but_SCAN_and_ADMIN_on_global_group() { + permissionService.getAllProjectPermissions() + .stream() + .filter(perm -> !GlobalPermission.SCAN.getKey().equals(perm) && !GlobalPermission.ADMINISTER.getKey().equals(perm)) + .forEach(permission -> { + try { + new GroupPermissionChange(Operation.ADD, permission, null, group, permissionService); + fail("a BadRequestException should have been thrown for permission " + permission); + } catch (BadRequestException e) { + assertThat(e).hasMessage("Invalid global permission '" + permission + "'. Valid values are [admin, gateadmin, profileadmin, provisioning, scan]"); + } + }); + } + + @Test + public void remove_permission_from_group() { + db.users().insertPermissionOnGroup(group, ADMINISTER_QUALITY_GATES); + db.users().insertPermissionOnGroup(group, PROVISION_PROJECTS); + + apply(new GroupPermissionChange(Operation.REMOVE, ADMINISTER_QUALITY_GATES.getKey(), null, group, permissionService), ADMINISTER_QUALITY_GATES.getKey(), + PROVISION_PROJECTS.getKey()); + + assertThat(db.users().selectGroupPermissions(group, null)).containsOnly(PROVISION_PROJECTS.getKey()); + } + + @Test + public void remove_project_permission_from_group() { + db.users().insertPermissionOnGroup(group, ADMINISTER_QUALITY_GATES); + db.users().insertEntityPermissionOnGroup(group, UserRole.ISSUE_ADMIN, privateProject); + db.users().insertEntityPermissionOnGroup(group, UserRole.CODEVIEWER, privateProject); + + apply(new GroupPermissionChange(Operation.REMOVE, UserRole.ISSUE_ADMIN, privateProject, group, permissionService), UserRole.ISSUE_ADMIN, + UserRole.CODEVIEWER); + + assertThat(db.users().selectGroupPermissions(group, null)).containsOnly(ADMINISTER_QUALITY_GATES.getKey()); + assertThat(db.users().selectGroupPermissions(group, privateProject)).containsOnly(UserRole.CODEVIEWER); + } + + @Test + public void do_not_fail_if_removing_a_permission_that_does_not_exist() { + apply(new GroupPermissionChange(Operation.REMOVE, UserRole.ISSUE_ADMIN, privateProject, group, permissionService)); + + assertThat(db.users().selectGroupPermissions(group, null)).isEmpty(); + assertThat(db.users().selectGroupPermissions(group, privateProject)).isEmpty(); + } + + @Test + public void fail_to_remove_admin_permission_if_no_more_admins() { + db.users().insertPermissionOnGroup(group, ADMINISTER); + + GroupPermissionChange change = new GroupPermissionChange(Operation.REMOVE, ADMINISTER.getKey(), null, group, permissionService); + Set<String> permission = Set.of("admin"); + DbSession session = db.getSession(); + assertThatThrownBy(() -> underTest.apply(session, permission, change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Last group with permission 'admin'. Permission cannot be removed."); + } + + @Test + public void remove_admin_group_if_still_other_admins() { + db.users().insertPermissionOnGroup(group, ADMINISTER); + UserDto admin = db.users().insertUser(); + db.users().insertGlobalPermissionOnUser(admin, ADMINISTER); + + apply(new GroupPermissionChange(Operation.REMOVE, ADMINISTER.getKey(), null, group, permissionService), ADMINISTER.getKey()); + + assertThat(db.users().selectGroupPermissions(group, null)).isEmpty(); + } + + private void apply(GroupPermissionChange change, String... existingPermissions) { + underTest.apply(db.getSession(), Set.of(existingPermissions), change); + db.commit(); + } + + private void unsafeInsertProjectPermissionOnAnyone(String perm) { + GroupPermissionDto dto = new GroupPermissionDto() + .setUuid(Uuids.createFast()) + .setGroupUuid(null) + .setRole(perm) + .setEntityUuid(privateProject.getUuid()) + .setEntityName(privateProject.getName()); + db.getDbClient().groupPermissionDao().insert(db.getSession(), dto, privateProject, null); + db.commit(); + } +} diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/PermissionTemplateServiceIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/PermissionTemplateServiceIT.java new file mode 100644 index 00000000000..d7095c13bcd --- /dev/null +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/PermissionTemplateServiceIT.java @@ -0,0 +1,484 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.web.UserRole; +import org.sonar.core.util.SequenceUuidFactory; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.db.component.ResourceTypesRule; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.permission.template.PermissionTemplateDbTester; +import org.sonar.db.permission.template.PermissionTemplateDto; +import org.sonar.db.portfolio.PortfolioDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.es.Indexers; +import org.sonar.server.es.TestIndexers; +import org.sonar.server.exceptions.TemplateMatchingKeyException; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.permission.PermissionServiceImpl; +import org.sonar.server.tester.UserSessionRule; + +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.sonar.api.resources.Qualifiers.APP; +import static org.sonar.api.resources.Qualifiers.PROJECT; +import static org.sonar.api.resources.Qualifiers.VIEW; + +public class PermissionTemplateServiceIT { + + @Rule + public DbTester dbTester = DbTester.create(); + + private final ResourceTypesRule resourceTypesRule = new ResourceTypesRule().setRootQualifiers(PROJECT, VIEW, APP); + private final DefaultTemplatesResolver defaultTemplatesResolver = new DefaultTemplatesResolverImpl(dbTester.getDbClient(), resourceTypesRule); + private final PermissionService permissionService = new PermissionServiceImpl(resourceTypesRule); + private final UserSessionRule userSession = UserSessionRule.standalone(); + private final PermissionTemplateDbTester templateDb = dbTester.permissionTemplates(); + private final DbSession session = dbTester.getSession(); + private final Indexers indexers = new TestIndexers(); + private final PermissionTemplateService underTest = new PermissionTemplateService(dbTester.getDbClient(), indexers, userSession, defaultTemplatesResolver, + new SequenceUuidFactory()); + + @Test + public void apply_does_not_insert_permission_to_group_AnyOne_when_applying_template_on_private_project() { + ProjectDto privateProject = dbTester.components().insertPrivateProject().getProjectDto(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, "p1"); + + underTest.applyAndCommit(session, permissionTemplate, singletonList(privateProject)); + + assertThat(selectProjectPermissionsOfGroup(null, privateProject.getUuid())).isEmpty(); + } + + @Test + public void apply_default_does_not_insert_permission_to_group_AnyOne_when_applying_template_on_private_project() { + ProjectDto privateProject = dbTester.components().insertPrivateProject().getProjectDto(); + UserDto creator = dbTester.users().insertUser(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, privateProject, creator.getUuid()); + + assertThat(selectProjectPermissionsOfGroup(null, privateProject.getUuid())).isEmpty(); + } + + @Test + public void apply_inserts_permissions_to_group_AnyOne_but_USER_and_CODEVIEWER_when_applying_template_on_public_project() { + ProjectDto publicProject = dbTester.components().insertPublicProject().getProjectDto(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, perm)); + dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, "p1"); + + underTest.applyAndCommit(session, permissionTemplate, singletonList(publicProject)); + + assertThat(selectProjectPermissionsOfGroup(null, publicProject.getUuid())) + .containsOnly("p1", UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void applyDefault_inserts_permissions_to_group_AnyOne_but_USER_and_CODEVIEWER_when_applying_template_on_public_project() { + ProjectDto publicProject = dbTester.components().insertPublicProject().getProjectDto(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, perm)); + dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, publicProject, null); + + assertThat(selectProjectPermissionsOfGroup(null, publicProject.getUuid())) + .containsOnly("p1", UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void apply_inserts_any_permissions_to_group_when_applying_template_on_private_project() { + ProjectDto privateProject = dbTester.components().insertPrivateProject().getProjectDto(); + GroupDto group = dbTester.users().insertGroup(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, perm)); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, "p1"); + + underTest.applyAndCommit(session, permissionTemplate, singletonList(privateProject)); + + assertThat(selectProjectPermissionsOfGroup(group, privateProject.getUuid())) + .containsOnly("p1", UserRole.CODEVIEWER, UserRole.USER, UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void applyDefault_inserts_any_permissions_to_group_when_applying_template_on_private_project() { + GroupDto group = dbTester.users().insertGroup(); + ProjectDto privateProject = dbTester.components().insertPrivateProject().getProjectDto(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, perm)); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, privateProject, null); + + assertThat(selectProjectPermissionsOfGroup(group, privateProject.getUuid())) + .containsOnly("p1", UserRole.CODEVIEWER, UserRole.USER, UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void apply_inserts_permissions_to_group_but_USER_and_CODEVIEWER_when_applying_template_on_public_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto publicProject = dbTester.components().insertPublicProject().getProjectDto(); + GroupDto group = dbTester.users().insertGroup(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, perm)); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, "p1"); + + underTest.applyAndCommit(session, permissionTemplate, singletonList(publicProject)); + + assertThat(selectProjectPermissionsOfGroup(group, publicProject.getUuid())) + .containsOnly("p1", UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void applyDefault_inserts_permissions_to_group_but_USER_and_CODEVIEWER_when_applying_template_on_public_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto publicProject = dbTester.components().insertPublicProject().getProjectDto(); + GroupDto group = dbTester.users().insertGroup(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, perm)); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, publicProject, null); + + assertThat(selectProjectPermissionsOfGroup(group, publicProject.getUuid())) + .containsOnly("p1", UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void apply_inserts_permissions_to_user_but_USER_and_CODEVIEWER_when_applying_template_on_public_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto publicProject = dbTester.components().insertPublicProject().getProjectDto(); + UserDto user = dbTester.users().insertUser(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, perm)); + dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, "p1"); + + underTest.applyAndCommit(session, permissionTemplate, singletonList(publicProject)); + + assertThat(selectProjectPermissionsOfUser(user, publicProject.getUuid())) + .containsOnly("p1", UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void applyDefault_inserts_permissions_to_user_but_USER_and_CODEVIEWER_when_applying_template_on_public_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto publicProject = dbTester.components().insertPublicProject().getProjectDto(); + UserDto user = dbTester.users().insertUser(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, perm)); + dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, publicProject, null); + + assertThat(selectProjectPermissionsOfUser(user, publicProject.getUuid())) + .containsOnly("p1", UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void apply_inserts_any_permissions_to_user_when_applying_template_on_private_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto privateProject = dbTester.components().insertPrivateProject().getProjectDto(); + UserDto user = dbTester.users().insertUser(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, perm)); + dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, "p1"); + + underTest.applyAndCommit(session, permissionTemplate, singletonList(privateProject)); + + assertThat(selectProjectPermissionsOfUser(user, privateProject.getUuid())) + .containsOnly("p1", UserRole.CODEVIEWER, UserRole.USER, UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void applyDefault_inserts_any_permissions_to_user_when_applying_template_on_private_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto privateProject = dbTester.components().insertPrivateProject().getProjectDto(); + UserDto user = dbTester.users().insertUser(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, perm)); + dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, privateProject, null); + + assertThat(selectProjectPermissionsOfUser(user, privateProject.getUuid())) + .containsOnly("p1", UserRole.CODEVIEWER, UserRole.USER, UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void applyDefault_inserts_permissions_to_ProjectCreator_but_USER_and_CODEVIEWER_when_applying_template_on_public_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto publicProject = dbTester.components().insertPublicProject().getProjectDto(); + UserDto user = dbTester.users().insertUser(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addProjectCreatorToTemplate(permissionTemplate, perm)); + dbTester.permissionTemplates().addProjectCreatorToTemplate(permissionTemplate, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, publicProject, user.getUuid()); + + assertThat(selectProjectPermissionsOfUser(user, publicProject.getUuid())) + .containsOnly("p1", UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void applyDefault_inserts_any_permissions_to_ProjectCreator_when_applying_template_on_private_project() { + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + ProjectDto privateProject = dbTester.components().insertPrivateProject().getProjectDto(); + UserDto user = dbTester.users().insertUser(); + permissionService.getAllProjectPermissions() + .forEach(perm -> dbTester.permissionTemplates().addProjectCreatorToTemplate(permissionTemplate, perm)); + dbTester.permissionTemplates().addProjectCreatorToTemplate(permissionTemplate, "p1"); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, privateProject, user.getUuid()); + + assertThat(selectProjectPermissionsOfUser(user, privateProject.getUuid())) + .containsOnly("p1", UserRole.CODEVIEWER, UserRole.USER, UserRole.ADMIN, UserRole.ISSUE_ADMIN, UserRole.SECURITYHOTSPOT_ADMIN, GlobalPermission.SCAN.getKey()); + } + + @Test + public void apply_template_on_view() { + PortfolioDto portfolio = dbTester.components().insertPrivatePortfolioDto(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + GroupDto group = dbTester.users().insertGroup(); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, GlobalPermission.ADMINISTER.getKey()); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, GlobalPermission.PROVISION_PROJECTS.getKey()); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, portfolio, null); + + assertThat(selectProjectPermissionsOfGroup(group, portfolio.getUuid())) + .containsOnly(GlobalPermission.ADMINISTER.getKey(), GlobalPermission.PROVISION_PROJECTS.getKey()); + } + + @Test + public void apply_default_template_on_application() { + ProjectDto application = dbTester.components().insertPublicApplication().getProjectDto(); + PermissionTemplateDto projectPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + PermissionTemplateDto appPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + GroupDto group = dbTester.users().insertGroup(); + dbTester.permissionTemplates().addGroupToTemplate(appPermissionTemplate, group, GlobalPermission.ADMINISTER.getKey()); + dbTester.permissionTemplates().addGroupToTemplate(appPermissionTemplate, group, GlobalPermission.PROVISION_PROJECTS.getKey()); + dbTester.permissionTemplates().setDefaultTemplates(projectPermissionTemplate, appPermissionTemplate, null); + + underTest.applyDefaultToNewComponent(session, application, null); + + assertThat(selectProjectPermissionsOfGroup(group, application.getUuid())) + .containsOnly(GlobalPermission.ADMINISTER.getKey(), GlobalPermission.PROVISION_PROJECTS.getKey()); + } + + @Test + public void apply_default_template_on_portfolio() { + PortfolioDto portfolio = dbTester.components().insertPublicPortfolioDto(); + PermissionTemplateDto projectPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + PermissionTemplateDto portPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + GroupDto group = dbTester.users().insertGroup(); + dbTester.permissionTemplates().addGroupToTemplate(portPermissionTemplate, group, GlobalPermission.ADMINISTER.getKey()); + dbTester.permissionTemplates().addGroupToTemplate(portPermissionTemplate, group, GlobalPermission.PROVISION_PROJECTS.getKey()); + dbTester.permissionTemplates().setDefaultTemplates(projectPermissionTemplate, null, portPermissionTemplate); + + underTest.applyDefaultToNewComponent(session, portfolio, null); + + assertThat(selectProjectPermissionsOfGroup(group, portfolio.getUuid())) + .containsOnly(GlobalPermission.ADMINISTER.getKey(), GlobalPermission.PROVISION_PROJECTS.getKey()); + } + + @Test + public void apply_project_default_template_on_view_when_no_view_default_template() { + PortfolioDto portfolio = dbTester.components().insertPrivatePortfolioDto(); + PermissionTemplateDto projectPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + GroupDto group = dbTester.users().insertGroup(); + dbTester.permissionTemplates().addGroupToTemplate(projectPermissionTemplate, group, GlobalPermission.PROVISION_PROJECTS.getKey()); + dbTester.permissionTemplates().setDefaultTemplates(projectPermissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, portfolio, null); + + assertThat(selectProjectPermissionsOfGroup(group, portfolio.getUuid())).containsOnly(GlobalPermission.PROVISION_PROJECTS.getKey()); + } + + @Test + public void apply_template_on_applications() { + ProjectDto application = dbTester.components().insertPublicApplication().getProjectDto(); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + GroupDto group = dbTester.users().insertGroup(); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, GlobalPermission.ADMINISTER.getKey()); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, group, GlobalPermission.PROVISION_PROJECTS.getKey()); + dbTester.permissionTemplates().setDefaultTemplates(permissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, application, null); + + assertThat(selectProjectPermissionsOfGroup(group, application.getUuid())) + .containsOnly(GlobalPermission.ADMINISTER.getKey(), GlobalPermission.PROVISION_PROJECTS.getKey()); + } + + @Test + public void apply_default_view_template_on_application() { + ProjectDto application = dbTester.components().insertPublicApplication().getProjectDto(); + PermissionTemplateDto projectPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + PermissionTemplateDto appPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + PermissionTemplateDto portPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + GroupDto group = dbTester.users().insertGroup(); + dbTester.permissionTemplates().addGroupToTemplate(appPermissionTemplate, group, GlobalPermission.ADMINISTER.getKey()); + dbTester.permissionTemplates().addGroupToTemplate(appPermissionTemplate, group, GlobalPermission.PROVISION_PROJECTS.getKey()); + dbTester.permissionTemplates().setDefaultTemplates(projectPermissionTemplate, appPermissionTemplate, portPermissionTemplate); + + underTest.applyDefaultToNewComponent(session, application, null); + + assertThat(selectProjectPermissionsOfGroup(group, application.getUuid())) + .containsOnly(GlobalPermission.ADMINISTER.getKey(), GlobalPermission.PROVISION_PROJECTS.getKey()); + } + + @Test + public void apply_project_default_template_on_application_when_no_application_default_template() { + ProjectDto application = dbTester.components().insertPublicApplication().getProjectDto(); + PermissionTemplateDto projectPermissionTemplate = dbTester.permissionTemplates().insertTemplate(); + GroupDto group = dbTester.users().insertGroup(); + dbTester.permissionTemplates().addGroupToTemplate(projectPermissionTemplate, group, GlobalPermission.PROVISION_PROJECTS.getKey()); + dbTester.permissionTemplates().setDefaultTemplates(projectPermissionTemplate, null, null); + + underTest.applyDefaultToNewComponent(session, application, null); + + assertThat(selectProjectPermissionsOfGroup(group, application.getUuid())).containsOnly(GlobalPermission.PROVISION_PROJECTS.getKey()); + } + + @Test + public void apply_permission_template() { + UserDto user = dbTester.users().insertUser(); + ProjectDto project = dbTester.components().insertPrivateProject().getProjectDto(); + GroupDto adminGroup = dbTester.users().insertGroup(); + GroupDto userGroup = dbTester.users().insertGroup(); + dbTester.users().insertPermissionOnGroup(adminGroup, GlobalPermission.ADMINISTER.getKey()); + dbTester.users().insertPermissionOnGroup(userGroup, UserRole.USER); + dbTester.users().insertGlobalPermissionOnUser(user, GlobalPermission.ADMINISTER); + PermissionTemplateDto permissionTemplate = dbTester.permissionTemplates().insertTemplate(); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, adminGroup, GlobalPermission.ADMINISTER.getKey()); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, adminGroup, UserRole.ISSUE_ADMIN); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, userGroup, UserRole.USER); + dbTester.permissionTemplates().addGroupToTemplate(permissionTemplate, userGroup, UserRole.CODEVIEWER); + dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, UserRole.USER); + dbTester.permissionTemplates().addAnyoneToTemplate(permissionTemplate, UserRole.CODEVIEWER); + dbTester.permissionTemplates().addUserToTemplate(permissionTemplate, user, GlobalPermission.ADMINISTER.getKey()); + + assertThat(selectProjectPermissionsOfGroup(adminGroup, project.getUuid())).isEmpty(); + assertThat(selectProjectPermissionsOfGroup(userGroup, project.getUuid())).isEmpty(); + assertThat(selectProjectPermissionsOfGroup(null, project.getUuid())).isEmpty(); + assertThat(selectProjectPermissionsOfUser(user, project.getUuid())).isEmpty(); + + underTest.applyAndCommit(session, permissionTemplate, singletonList(project)); + + assertThat(selectProjectPermissionsOfGroup(adminGroup, project.getUuid())).containsOnly(GlobalPermission.ADMINISTER.getKey(), UserRole.ISSUE_ADMIN); + assertThat(selectProjectPermissionsOfGroup(userGroup, project.getUuid())).containsOnly(UserRole.USER, UserRole.CODEVIEWER); + assertThat(selectProjectPermissionsOfGroup(null, project.getUuid())).isEmpty(); + assertThat(selectProjectPermissionsOfUser(user, project.getUuid())).containsOnly(GlobalPermission.ADMINISTER.getKey()); + } + + private List<String> selectProjectPermissionsOfGroup(@Nullable GroupDto groupDto, String projectUuid) { + return dbTester.getDbClient().groupPermissionDao().selectEntityPermissionsOfGroup(session, groupDto != null ? groupDto.getUuid() : null, projectUuid); + } + + private List<String> selectProjectPermissionsOfUser(UserDto userDto, String projectUuid) { + return dbTester.getDbClient().userPermissionDao().selectEntityPermissionsOfUser(session, userDto.getUuid(), projectUuid); + } + + @Test + public void would_user_have_scan_permission_with_default_permission_template() { + GroupDto group = dbTester.users().insertGroup(); + UserDto user = dbTester.users().insertUser(); + dbTester.users().insertMember(group, user); + PermissionTemplateDto template = templateDb.insertTemplate(); + dbTester.permissionTemplates().setDefaultTemplates(template, null, null); + templateDb.addProjectCreatorToTemplate(template.getUuid(), GlobalPermission.SCAN.getKey(), template.getName()); + templateDb.addUserToTemplate(template.getUuid(), user.getUuid(), UserRole.USER, template.getName(), user.getLogin()); + templateDb.addGroupToTemplate(template.getUuid(), group.getUuid(), UserRole.CODEVIEWER, template.getName(), group.getName()); + templateDb.addGroupToTemplate(template.getUuid(), null, UserRole.ISSUE_ADMIN, template.getName(), null); + + // authenticated user + checkWouldUserHaveScanPermission(user.getUuid(), true); + + // anonymous user + checkWouldUserHaveScanPermission(null, false); + } + + @Test + public void would_user_have_scan_permission_with_unknown_default_permission_template() { + dbTester.permissionTemplates().setDefaultTemplates("UNKNOWN_TEMPLATE_UUID", null, null); + + checkWouldUserHaveScanPermission(null, false); + } + + @Test + public void would_user_have_scan_permission_with_empty_template() { + PermissionTemplateDto template = templateDb.insertTemplate(); + dbTester.permissionTemplates().setDefaultTemplates(template, null, null); + + checkWouldUserHaveScanPermission(null, false); + } + + @Test + public void apply_permission_template_with_key_pattern_collision() { + final String key = "hi-test"; + final String keyPattern = ".*-test"; + + Stream<PermissionTemplateDto> templates = Stream.of( + templateDb.insertTemplate(t -> t.setKeyPattern(keyPattern)), + templateDb.insertTemplate(t -> t.setKeyPattern(keyPattern)) + ); + + String templateNames = templates + .map(PermissionTemplateDto::getName) + .sorted(String.CASE_INSENSITIVE_ORDER) + .map(x -> String.format("\"%s\"", x)) + .collect(Collectors.joining(", ")); + + ProjectDto project = dbTester.components().insertPrivateProject(p -> p.setKey(key)).getProjectDto(); + + assertThatThrownBy(() -> underTest.applyDefaultToNewComponent(session, project, null)) + .isInstanceOf(TemplateMatchingKeyException.class) + .hasMessageContaining("The \"%s\" key matches multiple permission templates: %s.", key, templateNames); + } + + private void checkWouldUserHaveScanPermission(@Nullable String userUuid, boolean expectedResult) { + assertThat(underTest.wouldUserHaveScanPermissionWithDefaultTemplate(session, userUuid, "PROJECT_KEY")) + .isEqualTo(expectedResult); + } + +} diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/UserPermissionChangerIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/UserPermissionChangerIT.java new file mode 100644 index 00000000000..479a28e81d4 --- /dev/null +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/permission/UserPermissionChangerIT.java @@ -0,0 +1,346 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.resources.ResourceTypes; +import org.sonar.api.utils.System2; +import org.sonar.api.web.UserRole; +import org.sonar.core.util.SequenceUuidFactory; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.db.component.ResourceTypesRule; +import org.sonar.db.entity.EntityDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDto; +import org.sonar.db.user.UserIdDto; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.permission.PermissionServiceImpl; + +import static java.util.stream.Collectors.toSet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.sonar.server.common.permission.Operation.ADD; +import static org.sonar.server.common.permission.Operation.REMOVE; +import static org.sonar.server.permission.PermissionServiceImpl.ALL_PROJECT_PERMISSIONS; + +public class UserPermissionChangerIT { + @Rule + public DbTester db = DbTester.create(System2.INSTANCE); + + private final ResourceTypes resourceTypes = new ResourceTypesRule().setRootQualifiers(Qualifiers.PROJECT); + private final PermissionService permissionService = new PermissionServiceImpl(resourceTypes); + private final UserPermissionChanger underTest = new UserPermissionChanger(db.getDbClient(), new SequenceUuidFactory()); + private UserDto user1; + private UserDto user2; + private EntityDto privateProject; + private EntityDto publicProject; + + @Before + public void setUp() { + user1 = db.users().insertUser(); + user2 = db.users().insertUser(); + privateProject = db.components().insertPrivateProject().getProjectDto(); + publicProject = db.components().insertPublicProject().getProjectDto(); + } + + @Test + public void apply_adds_any_global_permission_to_user() { + permissionService.getGlobalPermissions() + .forEach(perm -> { + UserPermissionChange change = new UserPermissionChange(ADD, perm.getKey(), null, UserIdDto.from(user1), permissionService); + + apply(change); + + assertThat(db.users().selectPermissionsOfUser(user1)).contains(perm); + }); + } + + @Test + public void apply_removes_any_global_permission_to_user() { + // give ADMIN perm to user2 so that user1 is not the only one with this permission and it can be removed from user1 + db.users().insertGlobalPermissionOnUser(user2, GlobalPermission.ADMINISTER); + permissionService.getGlobalPermissions() + .forEach(perm -> db.users().insertGlobalPermissionOnUser(user1, perm)); + assertThat(db.users().selectPermissionsOfUser(user1)) + .containsOnly(permissionService.getGlobalPermissions().toArray(new GlobalPermission[0])); + + permissionService.getGlobalPermissions() + .forEach(perm -> { + UserPermissionChange change = new UserPermissionChange(REMOVE, perm.getKey(), null, UserIdDto.from(user1), permissionService); + + apply(change, permissionService.getGlobalPermissions().stream().map(GlobalPermission::getKey).collect(toSet())); + + assertThat(db.users().selectPermissionsOfUser(user1)).doesNotContain(perm); + }); + } + + @Test + public void apply_has_no_effect_when_adding_permission_USER_on_a_public_project() { + UserPermissionChange change = new UserPermissionChange(ADD, UserRole.USER, publicProject, UserIdDto.from(user1), permissionService); + + apply(change); + + assertThat(db.users().selectEntityPermissionOfUser(user1, publicProject.getUuid())).doesNotContain(UserRole.USER); + } + + @Test + public void apply_has_no_effect_when_adding_permission_CODEVIEWER_on_a_public_project() { + UserPermissionChange change = new UserPermissionChange(ADD, UserRole.CODEVIEWER, publicProject, UserIdDto.from(user1), permissionService); + + apply(change); + + assertThat(db.users().selectEntityPermissionOfUser(user1, publicProject.getUuid())).doesNotContain(UserRole.CODEVIEWER); + } + + @Test + public void apply_adds_permission_ADMIN_on_a_public_project() { + applyAddsPermissionOnAPublicProject(UserRole.ADMIN); + } + + @Test + public void apply_adds_permission_ISSUE_ADMIN_on_a_public_project() { + applyAddsPermissionOnAPublicProject(UserRole.ISSUE_ADMIN); + } + + @Test + public void apply_adds_permission_SCAN_EXECUTION_on_a_public_project() { + applyAddsPermissionOnAPublicProject(GlobalPermission.SCAN.getKey()); + } + + private void applyAddsPermissionOnAPublicProject(String permission) { + UserPermissionChange change = new UserPermissionChange(ADD, permission, publicProject, UserIdDto.from(user1), permissionService); + + apply(change); + + assertThat(db.users().selectEntityPermissionOfUser(user1, publicProject.getUuid())).containsOnly(permission); + } + + @Test + public void apply_fails_with_BadRequestException_when_removing_permission_USER_from_a_public_project() { + UserPermissionChange change = new UserPermissionChange(REMOVE, UserRole.USER, publicProject, UserIdDto.from(user1), permissionService); + + assertThatThrownBy(() -> apply(change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Permission user can't be removed from a public component"); + } + + @Test + public void apply_fails_with_BadRequestException_when_removing_permission_CODEVIEWER_from_a_public_project() { + UserPermissionChange change = new UserPermissionChange(REMOVE, UserRole.CODEVIEWER, publicProject, UserIdDto.from(user1), permissionService); + + assertThatThrownBy(() -> apply(change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Permission codeviewer can't be removed from a public component"); + } + + @Test + public void apply_removes_permission_ADMIN_from_a_public_project() { + applyRemovesPermissionFromPublicProject(UserRole.ADMIN); + } + + @Test + public void apply_removes_permission_ISSUE_ADMIN_from_a_public_project() { + applyRemovesPermissionFromPublicProject(UserRole.ISSUE_ADMIN); + } + + @Test + public void apply_removes_permission_SCAN_EXECUTION_from_a_public_project() { + applyRemovesPermissionFromPublicProject(GlobalPermission.SCAN.getKey()); + } + + private void applyRemovesPermissionFromPublicProject(String permission) { + db.users().insertProjectPermissionOnUser(user1, permission, publicProject); + UserPermissionChange change = new UserPermissionChange(REMOVE, permission, publicProject, UserIdDto.from(user1), permissionService); + + apply(change, Set.of(permission)); + + assertThat(db.users().selectEntityPermissionOfUser(user1, publicProject.getUuid())).isEmpty(); + } + + @Test + public void apply_adds_any_permission_to_a_private_project() { + permissionService.getAllProjectPermissions() + .forEach(permission -> { + UserPermissionChange change = new UserPermissionChange(ADD, permission, privateProject, UserIdDto.from(user1), permissionService); + + apply(change); + + assertThat(db.users().selectEntityPermissionOfUser(user1, privateProject.getUuid())).contains(permission); + }); + } + + @Test + public void apply_removes_any_permission_from_a_private_project() { + permissionService.getAllProjectPermissions() + .forEach(permission -> db.users().insertProjectPermissionOnUser(user1, permission, privateProject)); + + permissionService.getAllProjectPermissions() + .forEach(permission -> { + UserPermissionChange change = new UserPermissionChange(REMOVE, permission, privateProject, UserIdDto.from(user1), permissionService); + + apply(change, ALL_PROJECT_PERMISSIONS); + + assertThat(db.users().selectEntityPermissionOfUser(user1, privateProject.getUuid())).doesNotContain(permission); + }); + } + + @Test + public void add_global_permission_to_user() { + UserPermissionChange change = new UserPermissionChange(ADD, GlobalPermission.SCAN.getKey(), null, UserIdDto.from(user1), permissionService); + + apply(change); + + assertThat(db.users().selectPermissionsOfUser(user1)).containsOnly(GlobalPermission.SCAN); + assertThat(db.users().selectEntityPermissionOfUser(user1, privateProject.getUuid())).isEmpty(); + assertThat(db.users().selectPermissionsOfUser(user2)).isEmpty(); + assertThat(db.users().selectEntityPermissionOfUser(user2, privateProject.getUuid())).isEmpty(); + } + + @Test + public void add_project_permission_to_user() { + UserPermissionChange change = new UserPermissionChange(ADD, UserRole.ISSUE_ADMIN, privateProject, UserIdDto.from(user1), permissionService); + apply(change); + + assertThat(db.users().selectPermissionsOfUser(user1)).isEmpty(); + assertThat(db.users().selectEntityPermissionOfUser(user1, privateProject.getUuid())).contains(UserRole.ISSUE_ADMIN); + assertThat(db.users().selectPermissionsOfUser(user2)).isEmpty(); + assertThat(db.users().selectEntityPermissionOfUser(user2, privateProject.getUuid())).isEmpty(); + } + + @Test + public void do_nothing_when_adding_global_permission_that_already_exists() { + db.users().insertGlobalPermissionOnUser(user1, GlobalPermission.ADMINISTER_QUALITY_GATES); + + UserPermissionChange change = new UserPermissionChange(ADD, GlobalPermission.ADMINISTER_QUALITY_GATES.getKey(), null, UserIdDto.from(user1), permissionService); + apply(change); + + assertThat(db.users().selectPermissionsOfUser(user1)).containsOnly(GlobalPermission.ADMINISTER_QUALITY_GATES); + } + + @Test + public void fail_to_add_global_permission_on_project() { + assertThatThrownBy(() -> { + UserPermissionChange change = new UserPermissionChange(ADD, GlobalPermission.ADMINISTER_QUALITY_GATES.getKey(), privateProject, UserIdDto.from(user1), permissionService); + apply(change); + }) + .isInstanceOf(BadRequestException.class) + .hasMessage("Invalid project permission 'gateadmin'. Valid values are [" + StringUtils.join(permissionService.getAllProjectPermissions(), ", ") + "]"); + } + + @Test + public void fail_to_add_project_permission() { + assertThatThrownBy(() -> { + UserPermissionChange change = new UserPermissionChange(ADD, UserRole.ISSUE_ADMIN, null, UserIdDto.from(user1), permissionService); + apply(change); + }) + .isInstanceOf(BadRequestException.class) + .hasMessage("Invalid global permission 'issueadmin'. Valid values are [admin, gateadmin, profileadmin, provisioning, scan]"); + } + + @Test + public void remove_global_permission_from_user() { + db.users().insertGlobalPermissionOnUser(user1, GlobalPermission.ADMINISTER_QUALITY_GATES); + db.users().insertGlobalPermissionOnUser(user1, GlobalPermission.SCAN); + db.users().insertGlobalPermissionOnUser(user2, GlobalPermission.ADMINISTER_QUALITY_GATES); + db.users().insertProjectPermissionOnUser(user1, UserRole.ISSUE_ADMIN, privateProject); + + UserPermissionChange change = new UserPermissionChange(REMOVE, GlobalPermission.ADMINISTER_QUALITY_GATES.getKey(), null, UserIdDto.from(user1), permissionService); + apply(change, Set.of(GlobalPermission.ADMINISTER_QUALITY_GATES.getKey(), GlobalPermission.SCAN.getKey(), UserRole.ISSUE_ADMIN)); + + assertThat(db.users().selectPermissionsOfUser(user1)).containsOnly(GlobalPermission.SCAN); + assertThat(db.users().selectPermissionsOfUser(user2)).containsOnly(GlobalPermission.ADMINISTER_QUALITY_GATES); + assertThat(db.users().selectEntityPermissionOfUser(user1, privateProject.getUuid())).containsOnly(UserRole.ISSUE_ADMIN); + } + + @Test + public void remove_project_permission_from_user() { + EntityDto project2 = db.components().insertPrivateProject().getProjectDto(); + db.users().insertGlobalPermissionOnUser(user1, GlobalPermission.ADMINISTER_QUALITY_GATES); + db.users().insertProjectPermissionOnUser(user1, UserRole.ISSUE_ADMIN, privateProject); + db.users().insertProjectPermissionOnUser(user1, UserRole.USER, privateProject); + db.users().insertProjectPermissionOnUser(user2, UserRole.ISSUE_ADMIN, privateProject); + db.users().insertProjectPermissionOnUser(user1, UserRole.ISSUE_ADMIN, project2); + + UserPermissionChange change = new UserPermissionChange(REMOVE, UserRole.ISSUE_ADMIN, privateProject, UserIdDto.from(user1), permissionService); + apply(change, Set.of(GlobalPermission.ADMINISTER_QUALITY_GATES.getKey(), UserRole.ISSUE_ADMIN, UserRole.USER)); + + assertThat(db.users().selectEntityPermissionOfUser(user1, privateProject.getUuid())).containsOnly(UserRole.USER); + assertThat(db.users().selectEntityPermissionOfUser(user2, privateProject.getUuid())).containsOnly(UserRole.ISSUE_ADMIN); + assertThat(db.users().selectEntityPermissionOfUser(user1, project2.getUuid())).containsOnly(UserRole.ISSUE_ADMIN); + } + + @Test + public void do_not_fail_if_removing_a_global_permission_that_does_not_exist() { + UserPermissionChange change = new UserPermissionChange(REMOVE, GlobalPermission.ADMINISTER_QUALITY_GATES.getKey(), null, UserIdDto.from(user1), permissionService); + apply(change); + + assertThat(db.users().selectPermissionsOfUser(user1)).isEmpty(); + } + + @Test + public void do_not_fail_if_removing_a_project_permission_that_does_not_exist() { + UserPermissionChange change = new UserPermissionChange(REMOVE, UserRole.ISSUE_ADMIN, privateProject, UserIdDto.from(user1), permissionService); + apply(change); + + assertThat(db.users().selectEntityPermissionOfUser(user1, privateProject.getUuid())).isEmpty(); + } + + @Test + public void fail_to_remove_admin_global_permission_if_no_more_admins() { + db.users().insertGlobalPermissionOnUser(user1, GlobalPermission.ADMINISTER); + + UserPermissionChange change = new UserPermissionChange(REMOVE, GlobalPermission.ADMINISTER.getKey(), null, UserIdDto.from(user1), permissionService); + DbSession session = db.getSession(); + Set<String> permissions = Set.of(GlobalPermission.ADMINISTER.getKey()); + assertThatThrownBy(() -> underTest.apply(session, permissions, change)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Last user with permission 'admin'. Permission cannot be removed."); + } + + @Test + public void remove_admin_user_if_still_other_admins() { + db.users().insertGlobalPermissionOnUser(user1, GlobalPermission.ADMINISTER); + GroupDto admins = db.users().insertGroup("admins"); + db.users().insertMember(admins, user2); + db.users().insertPermissionOnGroup(admins, GlobalPermission.ADMINISTER); + + UserPermissionChange change = new UserPermissionChange(REMOVE, GlobalPermission.ADMINISTER.getKey(), null, UserIdDto.from(user1), permissionService); + underTest.apply(db.getSession(), Set.of(GlobalPermission.ADMINISTER.getKey()), change); + + assertThat(db.users().selectPermissionsOfUser(user1)).isEmpty(); + } + + private void apply(UserPermissionChange change) { + underTest.apply(db.getSession(), Set.of(), change); + db.commit(); + } + private void apply(UserPermissionChange change, Set<String> existingPermissions) { + underTest.apply(db.getSession(), existingPermissions, change); + db.commit(); + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almintegration/ProjectKeyGenerator.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almintegration/ProjectKeyGenerator.java new file mode 100644 index 00000000000..cf5a04a2c8e --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almintegration/ProjectKeyGenerator.java @@ -0,0 +1,63 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almintegration; + +import com.google.common.annotations.VisibleForTesting; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.sonar.core.util.UuidFactory; + +import static com.google.common.collect.Lists.asList; +import static org.sonar.core.component.ComponentKeys.sanitizeProjectKey; + +public class ProjectKeyGenerator { + + @VisibleForTesting + static final int MAX_PROJECT_KEY_SIZE = 250; + @VisibleForTesting + static final Character PROJECT_KEY_SEPARATOR = '_'; + + private final UuidFactory uuidFactory; + + public ProjectKeyGenerator(UuidFactory uuidFactory) { + this.uuidFactory = uuidFactory; + } + + public String generateUniqueProjectKey(String projectName, String... extraProjectKeyItems) { + String sqProjectKey = generateCompleteProjectKey(projectName, extraProjectKeyItems); + sqProjectKey = truncateProjectKeyIfNecessary(sqProjectKey); + return sanitizeProjectKey(sqProjectKey); + } + + private String generateCompleteProjectKey(String projectName, String[] extraProjectKeyItems) { + List<String> projectKeyItems = asList(projectName, extraProjectKeyItems); + String projectKey = StringUtils.join(projectKeyItems, PROJECT_KEY_SEPARATOR); + String uuid = uuidFactory.create(); + return projectKey + PROJECT_KEY_SEPARATOR + uuid; + } + + private static String truncateProjectKeyIfNecessary(String sqProjectKey) { + if (sqProjectKey.length() > MAX_PROJECT_KEY_SIZE) { + return sqProjectKey.substring(sqProjectKey.length() - MAX_PROJECT_KEY_SIZE); + } + return sqProjectKey; + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almintegration/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almintegration/package-info.java new file mode 100644 index 00000000000..6cfcd9bc704 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almintegration/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.server.common.almintegration; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DelegatingDevOpsProjectCreatorFactory.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DelegatingDevOpsProjectCreatorFactory.java new file mode 100644 index 00000000000..dc0660d97cf --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DelegatingDevOpsProjectCreatorFactory.java @@ -0,0 +1,54 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Priority; +import org.sonar.api.server.ServerSide; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.AlmSettingDto; + +@ServerSide +@Priority(1) +public class DelegatingDevOpsProjectCreatorFactory implements DevOpsProjectCreatorFactory { + + private final Set<DevOpsProjectCreatorFactory> delegates; + + public DelegatingDevOpsProjectCreatorFactory(Set<DevOpsProjectCreatorFactory> delegates) { + this.delegates = delegates; + } + + @Override + public Optional<DevOpsProjectCreator> getDevOpsProjectCreator(DbSession dbSession, Map<String, String> characteristics) { + return delegates.stream() + .flatMap(delegate -> delegate.getDevOpsProjectCreator(dbSession, characteristics).stream()) + .findFirst(); + } + + @Override + public Optional<DevOpsProjectCreator> getDevOpsProjectCreator(AlmSettingDto almSettingDto, DevOpsProjectDescriptor devOpsProjectDescriptor) { + return delegates.stream() + .flatMap(delegate -> delegate.getDevOpsProjectCreator(almSettingDto, devOpsProjectDescriptor).stream()) + .findFirst(); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectCreator.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectCreator.java new file mode 100644 index 00000000000..86225fa1ac8 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectCreator.java @@ -0,0 +1,34 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings; + +import javax.annotation.Nullable; +import org.sonar.db.DbSession; +import org.sonar.db.project.CreationMethod; +import org.sonar.server.component.ComponentCreationData; + +public interface DevOpsProjectCreator { + + boolean isScanAllowedUsingPermissionsFromDevopsPlatform(); + + ComponentCreationData createProjectAndBindToDevOpsPlatform(DbSession dbSession, CreationMethod creationMethod, Boolean monorepo, @Nullable String projectKey, + @Nullable String projectName); + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectCreatorFactory.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectCreatorFactory.java new file mode 100644 index 00000000000..55835f45c23 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectCreatorFactory.java @@ -0,0 +1,33 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings; + +import java.util.Map; +import java.util.Optional; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.AlmSettingDto; + +public interface DevOpsProjectCreatorFactory { + + Optional<DevOpsProjectCreator> getDevOpsProjectCreator(DbSession dbSession, Map<String, String> characteristics); + + Optional<DevOpsProjectCreator> getDevOpsProjectCreator(AlmSettingDto almSettingDto, DevOpsProjectDescriptor devOpsProjectDescriptor); + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectDescriptor.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectDescriptor.java new file mode 100644 index 00000000000..4459ccbbf09 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/DevOpsProjectDescriptor.java @@ -0,0 +1,25 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings; + +import org.sonar.db.alm.setting.ALM; + +public record DevOpsProjectDescriptor(ALM alm, String url, String projectIdentifier) { +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreationParameters.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreationParameters.java new file mode 100644 index 00000000000..72b2ca87602 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreationParameters.java @@ -0,0 +1,32 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.github; + +import javax.annotation.Nullable; +import org.sonar.auth.github.AppInstallationToken; +import org.sonar.auth.github.security.AccessToken; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.user.UserSession; + +public record GithubProjectCreationParameters(DevOpsProjectDescriptor devOpsProjectDescriptor, AlmSettingDto almSettingDto, UserSession userSession, + AccessToken devOpsAppInstallationToken, + @Nullable AppInstallationToken authAppInstallationToken) { +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreator.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreator.java new file mode 100644 index 00000000000..47902dd9c98 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreator.java @@ -0,0 +1,229 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.github; + +import java.util.Optional; +import java.util.Set; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import org.sonar.alm.client.github.GithubPermissionConverter; +import org.sonar.api.web.UserRole; +import org.sonar.auth.github.AppInstallationToken; +import org.sonar.auth.github.GitHubSettings; +import org.sonar.auth.github.GsonRepositoryCollaborator; +import org.sonar.auth.github.GsonRepositoryPermissions; +import org.sonar.auth.github.GsonRepositoryTeam; +import org.sonar.auth.github.client.GithubApplicationClient; +import org.sonar.auth.github.client.GithubApplicationClient.Repository; +import org.sonar.auth.github.security.AccessToken; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.component.BranchDto; +import org.sonar.db.project.CreationMethod; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.provisioning.GithubPermissionsMappingDto; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserIdDto; +import org.sonar.server.common.almintegration.ProjectKeyGenerator; +import org.sonar.server.common.almsettings.DevOpsProjectCreator; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.common.permission.Operation; +import org.sonar.server.common.permission.PermissionUpdater; +import org.sonar.server.common.permission.UserPermissionChange; +import org.sonar.server.common.project.ProjectCreator; +import org.sonar.server.component.ComponentCreationData; +import org.sonar.server.management.ManagedProjectService; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.user.UserSession; + +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.toSet; +import static org.sonar.api.utils.Preconditions.checkState; + +public class GithubProjectCreator implements DevOpsProjectCreator { + + private final DbClient dbClient; + private final GithubApplicationClient githubApplicationClient; + private final GithubPermissionConverter githubPermissionConverter; + private final ProjectKeyGenerator projectKeyGenerator; + private final PermissionUpdater<UserPermissionChange> permissionUpdater; + private final PermissionService permissionService; + private final ManagedProjectService managedProjectService; + private final ProjectCreator projectCreator; + private final GithubProjectCreationParameters githubProjectCreationParameters; + private final DevOpsProjectDescriptor devOpsProjectDescriptor; + private final UserSession userSession; + private final AlmSettingDto almSettingDto; + private final AccessToken devOpsAppInstallationToken; + private final GitHubSettings gitHubSettings; + + @CheckForNull + private final AppInstallationToken authAppInstallationToken; + + public GithubProjectCreator(DbClient dbClient, GithubApplicationClient githubApplicationClient, GithubPermissionConverter githubPermissionConverter, + ProjectKeyGenerator projectKeyGenerator, PermissionUpdater<UserPermissionChange> permissionUpdater, PermissionService permissionService, + ManagedProjectService managedProjectService, ProjectCreator projectCreator, GithubProjectCreationParameters githubProjectCreationParameters, GitHubSettings gitHubSettings) { + + this.dbClient = dbClient; + this.githubApplicationClient = githubApplicationClient; + this.githubPermissionConverter = githubPermissionConverter; + this.projectKeyGenerator = projectKeyGenerator; + this.permissionUpdater = permissionUpdater; + this.permissionService = permissionService; + this.managedProjectService = managedProjectService; + this.projectCreator = projectCreator; + this.githubProjectCreationParameters = githubProjectCreationParameters; + userSession = githubProjectCreationParameters.userSession(); + almSettingDto = githubProjectCreationParameters.almSettingDto(); + devOpsProjectDescriptor = githubProjectCreationParameters.devOpsProjectDescriptor(); + devOpsAppInstallationToken = githubProjectCreationParameters.devOpsAppInstallationToken(); + authAppInstallationToken = githubProjectCreationParameters.authAppInstallationToken(); + this.gitHubSettings = gitHubSettings; + } + + @Override + public boolean isScanAllowedUsingPermissionsFromDevopsPlatform() { + checkState(githubProjectCreationParameters.authAppInstallationToken() != null, "An auth app token is required in case repository permissions checking is necessary."); + + String[] orgaAndRepoTokenified = devOpsProjectDescriptor.projectIdentifier().split("/"); + String organization = orgaAndRepoTokenified[0]; + String repository = orgaAndRepoTokenified[1]; + + Set<GithubPermissionsMappingDto> permissionsMappingDtos = dbClient.githubPermissionsMappingDao().findAll(dbClient.openSession(false)); + + boolean userHasDirectAccessToRepo = doesUserHaveScanPermission(organization, repository, permissionsMappingDtos); + if (userHasDirectAccessToRepo) { + return true; + } + return doesUserBelongToAGroupWithScanPermission(organization, repository, permissionsMappingDtos); + } + + private boolean doesUserHaveScanPermission(String organization, String repository, Set<GithubPermissionsMappingDto> permissionsMappingDtos) { + Set<GsonRepositoryCollaborator> repositoryCollaborators = githubApplicationClient.getRepositoryCollaborators(devOpsProjectDescriptor.url(), authAppInstallationToken, + organization, repository); + + String externalLogin = userSession.getExternalIdentity().map(UserSession.ExternalIdentity::login).orElse(null); + if (externalLogin == null) { + return false; + } + return repositoryCollaborators.stream() + .filter(gsonRepositoryCollaborator -> externalLogin.equals(gsonRepositoryCollaborator.name())) + .findAny() + .map(gsonRepositoryCollaborator -> hasScanPermission(permissionsMappingDtos, gsonRepositoryCollaborator.roleName(), gsonRepositoryCollaborator.permissions())) + .orElse(false); + } + + private boolean doesUserBelongToAGroupWithScanPermission(String organization, String repository, + Set<GithubPermissionsMappingDto> permissionsMappingDtos) { + Set<GsonRepositoryTeam> repositoryTeams = githubApplicationClient.getRepositoryTeams(devOpsProjectDescriptor.url(), authAppInstallationToken, organization, repository); + + Set<String> groupsOfUser = findUserMembershipOnSonarQube(organization); + return repositoryTeams.stream() + .filter(team -> hasScanPermission(permissionsMappingDtos, team.permission(), team.permissions())) + .map(GsonRepositoryTeam::name) + .anyMatch(groupsOfUser::contains); + } + + private Set<String> findUserMembershipOnSonarQube(String organization) { + return userSession.getGroups().stream() + .map(GroupDto::getName) + .filter(groupName -> groupName.contains("/")) + .map(name -> name.replaceFirst(organization + "/", "")) + .collect(toSet()); + } + + private boolean hasScanPermission(Set<GithubPermissionsMappingDto> permissionsMappingDtos, String role, GsonRepositoryPermissions permissions) { + Set<String> sonarqubePermissions = githubPermissionConverter.toSonarqubeRolesWithFallbackOnRepositoryPermissions(permissionsMappingDtos, + role, permissions); + return sonarqubePermissions.contains(UserRole.SCAN); + } + + @Override + public ComponentCreationData createProjectAndBindToDevOpsPlatform(DbSession dbSession, CreationMethod creationMethod, Boolean monorepo, @Nullable String projectKey, + @Nullable String projectName) { + String url = requireNonNull(almSettingDto.getUrl(), "DevOps Platform url cannot be null"); + Repository repository = githubApplicationClient.getRepository(url, devOpsAppInstallationToken, devOpsProjectDescriptor.projectIdentifier()) + .orElseThrow(() -> new IllegalStateException( + String.format("Impossible to find the repository '%s' on GitHub, using the devops config %s", devOpsProjectDescriptor.projectIdentifier(), almSettingDto.getKey()))); + + return createProjectAndBindToDevOpsPlatform(dbSession, monorepo, projectKey, projectName, almSettingDto, repository, creationMethod); + } + + private ComponentCreationData createProjectAndBindToDevOpsPlatform(DbSession dbSession, Boolean monorepo, @Nullable String projectKey, @Nullable String projectName, + AlmSettingDto almSettingDto, + Repository repository, CreationMethod creationMethod) { + String key = Optional.ofNullable(projectKey).orElse(getUniqueProjectKey(repository)); + + boolean isManaged = gitHubSettings.isProvisioningEnabled(); + + ComponentCreationData componentCreationData = projectCreator.createProject(dbSession, key, Optional.ofNullable(projectName).orElse(repository.getName()), + repository.getDefaultBranch(), creationMethod, + shouldProjectBePrivate(repository), isManaged); + ProjectDto projectDto = Optional.ofNullable(componentCreationData.projectDto()).orElseThrow(); + createProjectAlmSettingDto(dbSession, repository, projectDto, almSettingDto, monorepo); + addScanPermissionToCurrentUser(dbSession, projectDto); + + BranchDto mainBranchDto = Optional.ofNullable(componentCreationData.mainBranchDto()).orElseThrow(); + if (gitHubSettings.isProvisioningEnabled()) { + syncProjectPermissionsWithGithub(projectDto, mainBranchDto); + } + return componentCreationData; + } + + @CheckForNull + private Boolean shouldProjectBePrivate(Repository repository) { + if (gitHubSettings.isProvisioningEnabled() && gitHubSettings.isProjectVisibilitySynchronizationActivated()) { + return repository.isPrivate(); + } else if (gitHubSettings.isProvisioningEnabled()) { + return true; + } else { + return null; + } + } + + private void addScanPermissionToCurrentUser(DbSession dbSession, ProjectDto projectDto) { + UserIdDto userId = new UserIdDto(requireNonNull(userSession.getUuid()), requireNonNull(userSession.getLogin())); + UserPermissionChange scanPermission = new UserPermissionChange(Operation.ADD, UserRole.SCAN, projectDto, userId, permissionService); + permissionUpdater.apply(dbSession, Set.of(scanPermission)); + } + + private void syncProjectPermissionsWithGithub(ProjectDto projectDto, BranchDto mainBranchDto) { + String userUuid = requireNonNull(userSession.getUuid()); + managedProjectService.queuePermissionSyncTask(userUuid, mainBranchDto.getUuid(), projectDto.getUuid()); + } + + private String getUniqueProjectKey(Repository repository) { + return projectKeyGenerator.generateUniqueProjectKey(repository.getFullName()); + } + + private void createProjectAlmSettingDto(DbSession dbSession, Repository repo, ProjectDto projectDto, AlmSettingDto almSettingDto, Boolean monorepo) { + ProjectAlmSettingDto projectAlmSettingDto = new ProjectAlmSettingDto() + .setAlmSettingUuid(almSettingDto.getUuid()) + .setAlmRepo(repo.getFullName()) + .setAlmSlug(null) + .setProjectUuid(projectDto.getUuid()) + .setSummaryCommentEnabled(true) + .setMonorepo(monorepo); + dbClient.projectAlmSettingDao().insertOrUpdate(dbSession, projectAlmSettingDto, almSettingDto.getKey(), projectDto.getName(), projectDto.getKey()); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorFactory.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorFactory.java new file mode 100644 index 00000000000..2a1821c2165 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorFactory.java @@ -0,0 +1,174 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.github; + +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.alm.client.github.GithubGlobalSettingsValidator; +import org.sonar.alm.client.github.GithubPermissionConverter; +import org.sonar.api.server.ServerSide; +import org.sonar.auth.github.AppInstallationToken; +import org.sonar.auth.github.GitHubSettings; +import org.sonar.auth.github.GithubAppConfiguration; +import org.sonar.auth.github.client.GithubApplicationClient; +import org.sonar.auth.github.security.AccessToken; +import org.sonar.auth.github.security.UserAccessToken; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.common.almintegration.ProjectKeyGenerator; +import org.sonar.server.common.almsettings.DevOpsProjectCreator; +import org.sonar.server.common.almsettings.DevOpsProjectCreatorFactory; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.common.permission.PermissionUpdater; +import org.sonar.server.common.permission.UserPermissionChange; +import org.sonar.server.common.project.ProjectCreator; +import org.sonar.server.exceptions.BadConfigurationException; +import org.sonar.server.management.ManagedProjectService; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.user.UserSession; + +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; +import static org.sonar.core.ce.CeTaskCharacteristics.DEVOPS_PLATFORM_PROJECT_IDENTIFIER; +import static org.sonar.core.ce.CeTaskCharacteristics.DEVOPS_PLATFORM_URL; + +@ServerSide +public class GithubProjectCreatorFactory implements DevOpsProjectCreatorFactory { + private static final Logger LOG = LoggerFactory.getLogger(GithubProjectCreatorFactory.class); + + private final DbClient dbClient; + private final GithubGlobalSettingsValidator githubGlobalSettingsValidator; + private final GithubApplicationClient githubApplicationClient; + private final ProjectKeyGenerator projectKeyGenerator; + private final ProjectCreator projectCreator; + private final UserSession userSession; + private final GitHubSettings gitHubSettings; + private final GithubPermissionConverter githubPermissionConverter; + private final PermissionUpdater<UserPermissionChange> permissionUpdater; + private final PermissionService permissionService; + private final ManagedProjectService managedProjectService; + + public GithubProjectCreatorFactory(DbClient dbClient, GithubGlobalSettingsValidator githubGlobalSettingsValidator, + GithubApplicationClient githubApplicationClient, ProjectKeyGenerator projectKeyGenerator, UserSession userSession, + ProjectCreator projectCreator, GitHubSettings gitHubSettings, GithubPermissionConverter githubPermissionConverter, + PermissionUpdater<UserPermissionChange> permissionUpdater, PermissionService permissionService, ManagedProjectService managedProjectService) { + this.dbClient = dbClient; + this.githubGlobalSettingsValidator = githubGlobalSettingsValidator; + this.githubApplicationClient = githubApplicationClient; + this.projectKeyGenerator = projectKeyGenerator; + this.userSession = userSession; + this.projectCreator = projectCreator; + this.gitHubSettings = gitHubSettings; + this.githubPermissionConverter = githubPermissionConverter; + this.permissionUpdater = permissionUpdater; + this.permissionService = permissionService; + this.managedProjectService = managedProjectService; + } + + @Override + public Optional<DevOpsProjectCreator> getDevOpsProjectCreator(DbSession dbSession, Map<String, String> characteristics) { + String githubApiUrl = characteristics.get(DEVOPS_PLATFORM_URL); + String githubRepository = characteristics.get(DEVOPS_PLATFORM_PROJECT_IDENTIFIER); + if (githubApiUrl == null || githubRepository == null) { + return Optional.empty(); + } + DevOpsProjectDescriptor devOpsProjectDescriptor = new DevOpsProjectDescriptor(ALM.GITHUB, githubApiUrl, githubRepository); + + return dbClient.almSettingDao().selectByAlm(dbSession, ALM.GITHUB).stream() + .filter(almSettingDto -> devOpsProjectDescriptor.url().equals(almSettingDto.getUrl())) + .map(almSettingDto -> findInstallationIdAndCreateDevOpsProjectCreator(devOpsProjectDescriptor, almSettingDto)) + .flatMap(Optional::stream) + .findFirst(); + + } + + private Optional<DevOpsProjectCreator> findInstallationIdAndCreateDevOpsProjectCreator(DevOpsProjectDescriptor devOpsProjectDescriptor, + AlmSettingDto almSettingDto) { + GithubAppConfiguration githubAppConfiguration = githubGlobalSettingsValidator.validate(almSettingDto); + return findInstallationIdToAccessRepo(githubAppConfiguration, devOpsProjectDescriptor.projectIdentifier()) + .map(installationId -> generateAppInstallationToken(githubAppConfiguration, installationId)) + .map(appInstallationToken -> createGithubProjectCreator(devOpsProjectDescriptor, almSettingDto, appInstallationToken)); + } + + private GithubProjectCreator createGithubProjectCreator(DevOpsProjectDescriptor devOpsProjectDescriptor, AlmSettingDto almSettingDto, + AppInstallationToken appInstallationToken) { + LOG.info("DevOps configuration {} auto-detected for project {}", almSettingDto.getKey(), devOpsProjectDescriptor.projectIdentifier()); + Optional<AppInstallationToken> authAppInstallationToken = getAuthAppInstallationTokenIfNecessary(devOpsProjectDescriptor); + + GithubProjectCreationParameters githubProjectCreationParameters = new GithubProjectCreationParameters(devOpsProjectDescriptor, almSettingDto, userSession, appInstallationToken, + authAppInstallationToken.orElse(null)); + return new GithubProjectCreator(dbClient, githubApplicationClient, githubPermissionConverter, projectKeyGenerator, permissionUpdater, permissionService, + managedProjectService, projectCreator, githubProjectCreationParameters, gitHubSettings); + } + + @Override + public Optional<DevOpsProjectCreator> getDevOpsProjectCreator(AlmSettingDto almSettingDto, + DevOpsProjectDescriptor devOpsProjectDescriptor) { + if (almSettingDto.getAlm() != ALM.GITHUB) { + return Optional.empty(); + } + try (DbSession dbSession = dbClient.openSession(false)) { + AccessToken accessToken = getAccessToken(dbSession, almSettingDto); + Optional<AppInstallationToken> authAppInstallationToken = getAuthAppInstallationTokenIfNecessary(devOpsProjectDescriptor); + GithubProjectCreationParameters githubProjectCreationParameters = new GithubProjectCreationParameters(devOpsProjectDescriptor, almSettingDto, userSession, accessToken, + authAppInstallationToken.orElse(null)); + GithubProjectCreator githubProjectCreator = new GithubProjectCreator(dbClient, githubApplicationClient, githubPermissionConverter, projectKeyGenerator, permissionUpdater, + permissionService, managedProjectService, this.projectCreator, githubProjectCreationParameters, gitHubSettings); + return Optional.of(githubProjectCreator); + } + } + + private AccessToken getAccessToken(DbSession dbSession, AlmSettingDto almSettingDto) { + String userUuid = requireNonNull(userSession.getUuid(), "User UUID cannot be null."); + return dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto) + .map(AlmPatDto::getPersonalAccessToken) + .map(UserAccessToken::new) + .orElseThrow(() -> new IllegalArgumentException("No personal access token found")); + } + + private Optional<AppInstallationToken> getAuthAppInstallationTokenIfNecessary(DevOpsProjectDescriptor devOpsProjectDescriptor) { + if (gitHubSettings.isProvisioningEnabled()) { + GithubAppConfiguration githubAppConfiguration = new GithubAppConfiguration(Long.parseLong(gitHubSettings.appId()), gitHubSettings.privateKey(), gitHubSettings.apiURL()); + long installationId = findInstallationIdToAccessRepo(githubAppConfiguration, devOpsProjectDescriptor.projectIdentifier()) + .orElseThrow(() -> new BadConfigurationException("PROJECT", + format("GitHub auto-provisioning is activated. However the repo %s is not in the scope of the authentication application. " + + "The permissions can't be checked, and the project can not be created.", + devOpsProjectDescriptor.projectIdentifier()))); + return Optional.of(generateAppInstallationToken(githubAppConfiguration, installationId)); + } + return Optional.empty(); + } + + private Optional<Long> findInstallationIdToAccessRepo(GithubAppConfiguration githubAppConfiguration, String repositoryKey) { + return githubApplicationClient.getInstallationId(githubAppConfiguration, repositoryKey); + } + + private AppInstallationToken generateAppInstallationToken(GithubAppConfiguration githubAppConfiguration, long installationId) { + return githubApplicationClient.createAppInstallationToken(githubAppConfiguration, installationId) + .orElseThrow(() -> new IllegalStateException(format("Error while generating token for GitHub Api Url %s (installation id: %s)", + githubAppConfiguration.getApiEndpoint(), installationId))); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/package-info.java new file mode 100644 index 00000000000..651c6710b55 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/github/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.server.common.almsettings.github; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreator.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreator.java new file mode 100644 index 00000000000..2c530a0fc89 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreator.java @@ -0,0 +1,145 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.gitlab; + +import java.util.Optional; +import org.jetbrains.annotations.Nullable; +import org.sonar.alm.client.gitlab.GitLabBranch; +import org.sonar.alm.client.gitlab.GitlabApplicationClient; +import org.sonar.alm.client.gitlab.GitlabServerException; +import org.sonar.alm.client.gitlab.Project; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.project.CreationMethod; +import org.sonar.db.project.ProjectDto; +import org.sonar.server.common.almintegration.ProjectKeyGenerator; +import org.sonar.server.common.almsettings.DevOpsProjectCreator; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.common.project.ProjectCreator; +import org.sonar.server.component.ComponentCreationData; +import org.sonar.server.user.UserSession; + +import static java.lang.String.format; +import static java.util.Objects.requireNonNull; + +public class GitlabProjectCreator implements DevOpsProjectCreator { + + private final DbClient dbClient; + private final ProjectKeyGenerator projectKeyGenerator; + private final ProjectCreator projectCreator; + private final AlmSettingDto almSettingDto; + private final DevOpsProjectDescriptor devOpsProjectDescriptor; + private final GitlabApplicationClient gitlabApplicationClient; + private final UserSession userSession; + + public GitlabProjectCreator(DbClient dbClient, ProjectKeyGenerator projectKeyGenerator, ProjectCreator projectCreator, AlmSettingDto almSettingDto, + DevOpsProjectDescriptor devOpsProjectDescriptor, GitlabApplicationClient gitlabApplicationClient, UserSession userSession) { + this.dbClient = dbClient; + this.projectKeyGenerator = projectKeyGenerator; + this.projectCreator = projectCreator; + this.almSettingDto = almSettingDto; + this.devOpsProjectDescriptor = devOpsProjectDescriptor; + this.gitlabApplicationClient = gitlabApplicationClient; + this.userSession = userSession; + } + + @Override + public boolean isScanAllowedUsingPermissionsFromDevopsPlatform() { + throw new UnsupportedOperationException("Not Implemented"); + } + + @Override + public ComponentCreationData createProjectAndBindToDevOpsPlatform(DbSession dbSession, CreationMethod creationMethod, Boolean monorepo, @Nullable String projectKey, + @Nullable String projectName) { + + String pat = findPersonalAccessTokenOrThrow(dbSession, almSettingDto); + + String gitlabUrl = requireNonNull(almSettingDto.getUrl(), "DevOps Platform gitlabUrl cannot be null"); + + Long gitlabProjectId = getGitlabProjectId(); + Project gitlabProject = fetchGitlabProject(gitlabUrl, pat, gitlabProjectId); + + Optional<String> almDefaultBranch = getDefaultBranchOnGitlab(gitlabUrl, pat, gitlabProjectId); + ComponentCreationData componentCreationData = projectCreator.createProject( + dbSession, + getProjectKey(projectKey, gitlabProject), + getProjectName(projectName, gitlabProject), + almDefaultBranch.orElse(null), + creationMethod); + ProjectDto projectDto = Optional.ofNullable(componentCreationData.projectDto()).orElseThrow(); + + createProjectAlmSettingDto(dbSession, gitlabProjectId.toString(), projectDto, almSettingDto, monorepo); + return componentCreationData; + } + + private String findPersonalAccessTokenOrThrow(DbSession dbSession, AlmSettingDto almSettingDto) { + String userUuid = requireNonNull(userSession.getUuid(), "User UUID cannot be null."); + Optional<AlmPatDto> almPatDto = dbClient.almPatDao().selectByUserAndAlmSetting(dbSession, userUuid, almSettingDto); + return almPatDto.map(AlmPatDto::getPersonalAccessToken) + .orElseThrow(() -> new IllegalArgumentException(format("personal access token for '%s' is missing", almSettingDto.getKey()))); + } + + private Long getGitlabProjectId() { + try { + return Long.parseLong(devOpsProjectDescriptor.projectIdentifier()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(format("GitLab project identifier must be a number, was '%s'", devOpsProjectDescriptor.projectIdentifier())); + } + } + + private Project fetchGitlabProject(String gitlabUrl, String pat, Long gitlabProjectId) { + try { + return gitlabApplicationClient.getProject( + gitlabUrl, + pat, + gitlabProjectId); + } catch (GitlabServerException e) { + throw new IllegalStateException(format("Failed to fetch GitLab project with ID '%s' from '%s'", gitlabProjectId, gitlabUrl), e); + } + } + + private Optional<String> getDefaultBranchOnGitlab(String gitlabUrl, String pat, long gitlabProjectId) { + Optional<GitLabBranch> almMainBranch = gitlabApplicationClient.getBranches(gitlabUrl, pat, gitlabProjectId).stream().filter(GitLabBranch::isDefault).findFirst(); + return almMainBranch.map(GitLabBranch::getName); + } + + private String getProjectKey(@Nullable String projectKey, Project gitlabProject) { + return Optional.ofNullable(projectKey).orElseGet(() -> projectKeyGenerator.generateUniqueProjectKey(gitlabProject.getPathWithNamespace())); + } + + private static String getProjectName(@Nullable String projectName, Project gitlabProject) { + return Optional.ofNullable(projectName).orElse(gitlabProject.getName()); + } + + private void createProjectAlmSettingDto(DbSession dbSession, String gitlabProjectId, ProjectDto projectDto, AlmSettingDto almSettingDto, Boolean monorepo) { + ProjectAlmSettingDto projectAlmSettingDto = new ProjectAlmSettingDto() + .setAlmSettingUuid(almSettingDto.getUuid()) + .setAlmRepo(gitlabProjectId) + .setAlmSlug(null) + .setProjectUuid(projectDto.getUuid()) + .setSummaryCommentEnabled(true) + .setMonorepo(monorepo); + dbClient.projectAlmSettingDao().insertOrUpdate(dbSession, projectAlmSettingDto, almSettingDto.getKey(), projectDto.getName(), projectDto.getKey()); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorFactory.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorFactory.java new file mode 100644 index 00000000000..19176e8c41a --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorFactory.java @@ -0,0 +1,72 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.gitlab; + +import java.util.Map; +import java.util.Optional; +import org.sonar.alm.client.gitlab.GitlabApplicationClient; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.common.almintegration.ProjectKeyGenerator; +import org.sonar.server.common.almsettings.DevOpsProjectCreator; +import org.sonar.server.common.almsettings.DevOpsProjectCreatorFactory; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.common.project.ProjectCreator; +import org.sonar.server.user.UserSession; + +public class GitlabProjectCreatorFactory implements DevOpsProjectCreatorFactory { + private final DbClient dbClient; + private final ProjectKeyGenerator projectKeyGenerator; + private final ProjectCreator projectCreator; + private final GitlabApplicationClient gitlabApplicationClient; + private final UserSession userSession; + + public GitlabProjectCreatorFactory(DbClient dbClient, ProjectKeyGenerator projectKeyGenerator, ProjectCreator projectCreator, GitlabApplicationClient gitlabApplicationClient, + UserSession userSession) { + this.dbClient = dbClient; + this.projectKeyGenerator = projectKeyGenerator; + this.projectCreator = projectCreator; + this.gitlabApplicationClient = gitlabApplicationClient; + this.userSession = userSession; + } + + @Override + public Optional<DevOpsProjectCreator> getDevOpsProjectCreator(DbSession dbSession, Map<String, String> characteristics) { + return Optional.empty(); + } + + @Override + public Optional<DevOpsProjectCreator> getDevOpsProjectCreator(AlmSettingDto almSettingDto, DevOpsProjectDescriptor devOpsProjectDescriptor) { + if (almSettingDto.getAlm() != ALM.GITLAB) { + return Optional.empty(); + } + return Optional.of( + new GitlabProjectCreator( + dbClient, + projectKeyGenerator, + projectCreator, + almSettingDto, + devOpsProjectDescriptor, + gitlabApplicationClient, + userSession)); + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/package-info.java new file mode 100644 index 00000000000..f5cc5a1abc8 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/gitlab/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.server.common.almsettings.gitlab; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/package-info.java new file mode 100644 index 00000000000..2883960112a --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/almsettings/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.server.common.almsettings; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/ComponentCreationParameters.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/ComponentCreationParameters.java new file mode 100644 index 00000000000..4ba5d2c8431 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/ComponentCreationParameters.java @@ -0,0 +1,78 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.component; + +import javax.annotation.Nullable; +import org.sonar.db.project.CreationMethod; + +public record ComponentCreationParameters(NewComponent newComponent, + @Nullable String userUuid, + @Nullable String userLogin, + @Nullable String mainBranchName, + boolean isManaged, + CreationMethod creationMethod) { + + public static ProjectCreationDataBuilder builder() { + return new ProjectCreationDataBuilder(); + } + + public static final class ProjectCreationDataBuilder { + private NewComponent newComponent; + private String userUuid = null; + private String userLogin = null; + private String mainBranchName = null; + private boolean isManaged = false; + private CreationMethod creationMethod; + + public ProjectCreationDataBuilder newComponent(NewComponent newComponent) { + this.newComponent = newComponent; + return this; + } + + public ProjectCreationDataBuilder userUuid(@Nullable String userUuid) { + this.userUuid = userUuid; + return this; + } + + public ProjectCreationDataBuilder userLogin(@Nullable String userLogin) { + this.userLogin = userLogin; + return this; + } + + public ProjectCreationDataBuilder mainBranchName(@Nullable String mainBranchName) { + this.mainBranchName = mainBranchName; + return this; + } + + public ProjectCreationDataBuilder isManaged(boolean isManaged) { + this.isManaged = isManaged; + return this; + } + + public ProjectCreationDataBuilder creationMethod(CreationMethod creationMethod) { + this.creationMethod = creationMethod; + return this; + } + + public ComponentCreationParameters build() { + return new ComponentCreationParameters(newComponent, userUuid, userLogin, mainBranchName, isManaged, creationMethod); + } + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/ComponentUpdater.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/ComponentUpdater.java new file mode 100644 index 00000000000..e42e77a33e1 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/ComponentUpdater.java @@ -0,0 +1,259 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.component; + +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.resources.Scopes; +import org.sonar.api.utils.System2; +import org.sonar.core.i18n.I18n; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.component.BranchDto; +import org.sonar.db.component.BranchType; +import org.sonar.db.component.ComponentDto; +import org.sonar.db.portfolio.PortfolioDto; +import org.sonar.db.portfolio.PortfolioDto.SelectionMode; +import org.sonar.db.project.CreationMethod; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.common.permission.Operation; +import org.sonar.server.common.permission.PermissionTemplateService; +import org.sonar.server.common.permission.PermissionUpdater; +import org.sonar.server.common.permission.UserPermissionChange; +import org.sonar.server.component.ComponentCreationData; +import org.sonar.server.es.Indexers; +import org.sonar.server.favorite.FavoriteUpdater; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.project.DefaultBranchNameResolver; + +import static com.google.common.base.Preconditions.checkState; +import static java.util.Collections.singletonList; +import static org.sonar.api.web.UserRole.PUBLIC_PERMISSIONS; +import static org.sonar.core.component.ComponentKeys.ALLOWED_CHARACTERS_MESSAGE; +import static org.sonar.core.component.ComponentKeys.isValidProjectKey; +import static org.sonar.server.exceptions.BadRequestException.checkRequest; +import static org.sonar.server.exceptions.BadRequestException.throwBadRequestException; + +public class ComponentUpdater { + + private static final Set<String> PROJ_APP_QUALIFIERS = Set.of(Qualifiers.PROJECT, Qualifiers.APP); + private static final String KEY_ALREADY_EXISTS_ERROR = "Could not create %s with key: \"%s\". A similar key already exists: \"%s\""; + private static final String MALFORMED_KEY_ERROR = "Malformed key for %s: '%s'. %s."; + private final DbClient dbClient; + private final I18n i18n; + private final System2 system2; + private final PermissionTemplateService permissionTemplateService; + private final FavoriteUpdater favoriteUpdater; + private final Indexers indexers; + private final UuidFactory uuidFactory; + private final DefaultBranchNameResolver defaultBranchNameResolver; + private final PermissionUpdater<UserPermissionChange> userPermissionUpdater; + private final PermissionService permissionService; + + public ComponentUpdater(DbClient dbClient, I18n i18n, System2 system2, + PermissionTemplateService permissionTemplateService, FavoriteUpdater favoriteUpdater, + Indexers indexers, UuidFactory uuidFactory, DefaultBranchNameResolver defaultBranchNameResolver, PermissionUpdater<UserPermissionChange> userPermissionUpdater, + PermissionService permissionService) { + this.dbClient = dbClient; + this.i18n = i18n; + this.system2 = system2; + this.permissionTemplateService = permissionTemplateService; + this.favoriteUpdater = favoriteUpdater; + this.indexers = indexers; + this.uuidFactory = uuidFactory; + this.defaultBranchNameResolver = defaultBranchNameResolver; + this.userPermissionUpdater = userPermissionUpdater; + this.permissionService = permissionService; + } + + /** + * - Create component + * - Apply default permission template + * - Add component to favorite if the component has the 'Project Creators' permission + * - Index component in es indexes + */ + public ComponentCreationData create(DbSession dbSession, ComponentCreationParameters componentCreationParameters) { + ComponentCreationData componentCreationData = createWithoutCommit(dbSession, componentCreationParameters); + commitAndIndex(dbSession, componentCreationData); + return componentCreationData; + } + + public void commitAndIndex(DbSession dbSession, ComponentCreationData componentCreationData) { + if (componentCreationData.portfolioDto() != null) { + indexers.commitAndIndexEntities(dbSession, singletonList(componentCreationData.portfolioDto()), Indexers.EntityEvent.CREATION); + } else if (componentCreationData.projectDto() != null) { + indexers.commitAndIndexEntities(dbSession, singletonList(componentCreationData.projectDto()), Indexers.EntityEvent.CREATION); + } + } + + /** + * Create component without committing. + * Don't forget to call commitAndIndex(...) when ready to commit. + */ + public ComponentCreationData createWithoutCommit(DbSession dbSession, ComponentCreationParameters componentCreationParameters) { + checkKeyFormat(componentCreationParameters.newComponent().qualifier(), componentCreationParameters.newComponent().key()); + checkKeyAlreadyExists(dbSession, componentCreationParameters.newComponent()); + + long now = system2.now(); + + ComponentDto componentDto = createRootComponent(dbSession, componentCreationParameters.newComponent(), now); + + BranchDto mainBranch = null; + ProjectDto projectDto = null; + PortfolioDto portfolioDto = null; + + if (isProjectOrApp(componentDto)) { + projectDto = toProjectDto(componentDto, now, componentCreationParameters.creationMethod()); + dbClient.projectDao().insert(dbSession, projectDto); + addToFavourites(dbSession, projectDto, componentCreationParameters.userUuid(), componentCreationParameters.userLogin()); + mainBranch = createMainBranch(dbSession, componentDto.uuid(), projectDto.getUuid(), componentCreationParameters.mainBranchName()); + if (componentCreationParameters.isManaged()) { + applyPublicPermissionsForCreator(dbSession, projectDto, componentCreationParameters.userUuid()); + } else { + permissionTemplateService.applyDefaultToNewComponent(dbSession, projectDto, componentCreationParameters.userUuid()); + } + } else if (isPortfolio(componentDto)) { + portfolioDto = toPortfolioDto(componentDto, now); + dbClient.portfolioDao().insert(dbSession, portfolioDto, false); + permissionTemplateService.applyDefaultToNewComponent(dbSession, portfolioDto, componentCreationParameters.userUuid()); + } else { + throw new IllegalArgumentException("Component " + componentDto + " is not a top level entity"); + } + + return new ComponentCreationData(componentDto, portfolioDto, mainBranch, projectDto); + } + + private void applyPublicPermissionsForCreator(DbSession dbSession, ProjectDto projectDto, @Nullable String userUuid) { + if (userUuid != null) { + UserDto userDto = dbClient.userDao().selectByUuid(dbSession, userUuid); + checkState(userDto != null, "User with uuid '%s' doesn't exist", userUuid); + userPermissionUpdater.apply(dbSession, + PUBLIC_PERMISSIONS.stream() + .map(permission -> toUserPermissionChange(permission, projectDto, userDto)) + .collect(Collectors.toSet())); + } + } + + private UserPermissionChange toUserPermissionChange(String permission, ProjectDto projectDto, UserDto userDto) { + return new UserPermissionChange(Operation.ADD, permission, projectDto, userDto, permissionService); + } + + private void addToFavourites(DbSession dbSession, ProjectDto projectDto, @Nullable String userUuid, @Nullable String userLogin) { + if (permissionTemplateService.hasDefaultTemplateWithPermissionOnProjectCreator(dbSession, projectDto)) { + favoriteUpdater.add(dbSession, projectDto, userUuid, userLogin, false); + } + } + + private void checkKeyFormat(String qualifier, String key) { + checkRequest(isValidProjectKey(key), MALFORMED_KEY_ERROR, getQualifierToDisplay(qualifier), key, ALLOWED_CHARACTERS_MESSAGE); + } + + private void checkKeyAlreadyExists(DbSession dbSession, NewComponent newComponent) { + List<ComponentDto> componentDtos = dbClient.componentDao().selectByKeyCaseInsensitive(dbSession, newComponent.key()); + + if (!componentDtos.isEmpty()) { + String alreadyExistingKeys = componentDtos + .stream() + .map(ComponentDto::getKey) + .collect(Collectors.joining(", ")); + throwBadRequestException(KEY_ALREADY_EXISTS_ERROR, getQualifierToDisplay(newComponent.qualifier()), newComponent.key(), alreadyExistingKeys); + } + } + + private ComponentDto createRootComponent(DbSession session, NewComponent newComponent, long now) { + String uuid = uuidFactory.create(); + + ComponentDto component = new ComponentDto() + .setUuid(uuid) + .setUuidPath(ComponentDto.UUID_PATH_OF_ROOT) + .setBranchUuid(uuid) + .setKey(newComponent.key()) + .setName(newComponent.name()) + .setDescription(newComponent.description()) + .setLongName(newComponent.name()) + .setScope(Scopes.PROJECT) + .setQualifier(newComponent.qualifier()) + .setPrivate(newComponent.isPrivate()) + .setCreatedAt(new Date(now)); + + dbClient.componentDao().insert(session, component, true); + return component; + } + + private ProjectDto toProjectDto(ComponentDto component, long now, CreationMethod creationMethod) { + return new ProjectDto() + .setUuid(uuidFactory.create()) + .setKey(component.getKey()) + .setQualifier(component.qualifier()) + .setName(component.name()) + .setPrivate(component.isPrivate()) + .setDescription(component.description()) + .setCreationMethod(creationMethod) + .setUpdatedAt(now) + .setCreatedAt(now); + } + + private static PortfolioDto toPortfolioDto(ComponentDto component, long now) { + return new PortfolioDto() + .setUuid(component.uuid()) + .setRootUuid(component.branchUuid()) + .setKey(component.getKey()) + .setName(component.name()) + .setPrivate(component.isPrivate()) + .setDescription(component.description()) + .setSelectionMode(SelectionMode.NONE.name()) + .setUpdatedAt(now) + .setCreatedAt(now); + } + + private static boolean isProjectOrApp(ComponentDto componentDto) { + return PROJ_APP_QUALIFIERS.contains(componentDto.qualifier()); + } + + private static boolean isPortfolio(ComponentDto componentDto) { + return Qualifiers.VIEW.contains(componentDto.qualifier()); + } + + private BranchDto createMainBranch(DbSession session, String componentUuid, String projectUuid, @Nullable String mainBranch) { + BranchDto branch = new BranchDto() + .setBranchType(BranchType.BRANCH) + .setUuid(componentUuid) + .setIsMain(true) + .setKey(Optional.ofNullable(mainBranch).orElse(defaultBranchNameResolver.getEffectiveMainBranchName())) + .setMergeBranchUuid(null) + .setExcludeFromPurge(true) + .setProjectUuid(projectUuid); + dbClient.branchDao().upsert(session, branch); + return branch; + } + + private String getQualifierToDisplay(String qualifier) { + return i18n.message(Locale.getDefault(), "qualifier." + qualifier, "Project"); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/NewComponent.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/NewComponent.java new file mode 100644 index 00000000000..8167bdae6ab --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/NewComponent.java @@ -0,0 +1,120 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.component; + +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import static org.sonar.api.resources.Qualifiers.PROJECT; +import static org.sonar.db.component.ComponentValidator.checkComponentKey; +import static org.sonar.db.component.ComponentValidator.checkComponentName; +import static org.sonar.db.component.ComponentValidator.checkComponentQualifier; + +@Immutable +public class NewComponent { + private final String key; + private final String qualifier; + private final String name; + private final String description; + private final boolean isPrivate; + + private NewComponent(NewComponent.Builder builder) { + this.key = builder.key; + this.qualifier = builder.qualifier; + this.name = builder.name; + this.isPrivate = builder.isPrivate; + this.description = builder.description; + } + + public static Builder newComponentBuilder() { + return new Builder(); + } + + public String key() { + return key; + } + + public String name() { + return name; + } + + public String qualifier() { + return qualifier; + } + + public boolean isPrivate() { + return isPrivate; + } + + @CheckForNull + public String description() { + return description; + } + + public boolean isProject() { + return PROJECT.equals(qualifier); + } + + public static class Builder { + private String description; + private String key; + private String qualifier = PROJECT; + private String name; + private boolean isPrivate = false; + + private Builder() { + // use static factory method newComponentBuilder() + } + + public Builder setKey(String key) { + this.key = key; + return this; + } + + public Builder setQualifier(String qualifier) { + this.qualifier = qualifier; + return this; + } + + public Builder setName(String name) { + this.name = name; + return this; + } + + public Builder setPrivate(boolean isPrivate) { + this.isPrivate = isPrivate; + return this; + } + + public Builder setDescription(@Nullable String description) { + this.description = description; + return this; + } + + public NewComponent build() { + checkComponentKey(key); + checkComponentName(name); + checkComponentQualifier(qualifier); + return new NewComponent(this); + } + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/package-info.java new file mode 100644 index 00000000000..51d90d85a8c --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/component/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.server.common.component; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/CaycUtils.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/CaycUtils.java new file mode 100644 index 00000000000..b206539df16 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/CaycUtils.java @@ -0,0 +1,39 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.newcodeperiod; + +import org.sonar.db.newcodeperiod.NewCodePeriodType; + +public interface CaycUtils { + static boolean isNewCodePeriodCompliant(NewCodePeriodType type, String value) { + if (type == NewCodePeriodType.NUMBER_OF_DAYS) { + return parseDays(value) > 0 && parseDays(value) <= 90; + } + return true; + } + + static int parseDays(String value) { + try { + return Integer.parseInt(value); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse number of days: " + value); + } + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/NewCodeDefinitionResolver.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/NewCodeDefinitionResolver.java new file mode 100644 index 00000000000..8956831e8cf --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/NewCodeDefinitionResolver.java @@ -0,0 +1,149 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.newcodeperiod; + +import com.google.common.base.Preconditions; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Optional; +import javax.annotation.Nullable; +import org.sonar.core.platform.EditionProvider; +import org.sonar.core.platform.PlatformEditionProvider; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.newcodeperiod.NewCodePeriodDto; +import org.sonar.db.newcodeperiod.NewCodePeriodParser; +import org.sonar.db.newcodeperiod.NewCodePeriodType; + +import static org.sonar.db.newcodeperiod.NewCodePeriodType.NUMBER_OF_DAYS; +import static org.sonar.db.newcodeperiod.NewCodePeriodType.PREVIOUS_VERSION; +import static org.sonar.db.newcodeperiod.NewCodePeriodType.REFERENCE_BRANCH; + +public class NewCodeDefinitionResolver { + private static final String BEGIN_LIST = "<ul>"; + + private static final String END_LIST = "</ul>"; + private static final String BEGIN_ITEM_LIST = "<li>"; + private static final String END_ITEM_LIST = "</li>"; + + public static final String NEW_CODE_PERIOD_TYPE_DESCRIPTION_PROJECT_CREATION = "Project New Code Definition Type<br/>" + + "New code definitions of the following types are allowed:" + + BEGIN_LIST + + BEGIN_ITEM_LIST + PREVIOUS_VERSION.name() + END_ITEM_LIST + + BEGIN_ITEM_LIST + NUMBER_OF_DAYS.name() + END_ITEM_LIST + + BEGIN_ITEM_LIST + REFERENCE_BRANCH.name() + " - will default to the main branch." + END_ITEM_LIST + + END_LIST; + + public static final String NEW_CODE_PERIOD_VALUE_DESCRIPTION_PROJECT_CREATION = "Project New Code Definition Value<br/>" + + "For each new code definition type, a different value is expected:" + + BEGIN_LIST + + BEGIN_ITEM_LIST + "no value, when the new code definition type is " + PREVIOUS_VERSION.name() + " and " + REFERENCE_BRANCH.name() + END_ITEM_LIST + + BEGIN_ITEM_LIST + "a number between 1 and 90, when the new code definition type is " + NUMBER_OF_DAYS.name() + END_ITEM_LIST + + END_LIST; + + private static final String UNEXPECTED_VALUE_ERROR_MESSAGE = "Unexpected value for newCodeDefinitionType '%s'"; + + private static final EnumSet<NewCodePeriodType> projectCreationNCDTypes = EnumSet.of(PREVIOUS_VERSION, NUMBER_OF_DAYS, REFERENCE_BRANCH); + + private final DbClient dbClient; + private final PlatformEditionProvider editionProvider; + + public NewCodeDefinitionResolver(DbClient dbClient, PlatformEditionProvider editionProvider) { + this.dbClient = dbClient; + this.editionProvider = editionProvider; + } + + public void createNewCodeDefinition(DbSession dbSession, String projectUuid, String mainBranchUuid, + String defaultBranchName, String newCodeDefinitionType, @Nullable String newCodeDefinitionValue) { + + boolean isCommunityEdition = editionProvider.get().filter(EditionProvider.Edition.COMMUNITY::equals).isPresent(); + NewCodePeriodType newCodePeriodType = parseNewCodeDefinitionType(newCodeDefinitionType); + + NewCodePeriodDto dto = new NewCodePeriodDto(); + dto.setType(newCodePeriodType); + dto.setProjectUuid(projectUuid); + + if (isCommunityEdition) { + dto.setBranchUuid(mainBranchUuid); + } + + getNewCodeDefinitionValueProjectCreation(newCodePeriodType, newCodeDefinitionValue, defaultBranchName).ifPresent(dto::setValue); + + if (!CaycUtils.isNewCodePeriodCompliant(dto.getType(), dto.getValue())) { + throw new IllegalArgumentException("Failed to set the New Code Definition. The given value is not compatible with the Clean as You Code methodology. " + + "Please refer to the documentation for compliant options."); + } + + dbClient.newCodePeriodDao().insert(dbSession, dto); + } + + public static void checkNewCodeDefinitionParam(@Nullable String newCodeDefinitionType, @Nullable String newCodeDefinitionValue) { + if (newCodeDefinitionType == null && newCodeDefinitionValue != null) { + throw new IllegalArgumentException("New code definition type is required when new code definition value is provided"); + } + } + + private static Optional<String> getNewCodeDefinitionValueProjectCreation(NewCodePeriodType type, @Nullable String value, String defaultBranchName) { + return switch (type) { + case PREVIOUS_VERSION -> { + Preconditions.checkArgument(value == null, UNEXPECTED_VALUE_ERROR_MESSAGE, type); + yield Optional.empty(); + } + case NUMBER_OF_DAYS -> { + requireValue(type, value); + yield Optional.of(parseDays(value)); + } + case REFERENCE_BRANCH -> { + Preconditions.checkArgument(value == null, UNEXPECTED_VALUE_ERROR_MESSAGE, type); + yield Optional.of(defaultBranchName); + } + default -> throw new IllegalStateException("Unexpected type: " + type); + }; + } + + private static String parseDays(String value) { + try { + return Integer.toString(NewCodePeriodParser.parseDays(value)); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse number of days: " + value); + } + } + + private static void requireValue(NewCodePeriodType type, @Nullable String value) { + Preconditions.checkArgument(value != null, "New code definition type '%s' requires a newCodeDefinitionValue", type); + } + + private static NewCodePeriodType parseNewCodeDefinitionType(String typeStr) { + NewCodePeriodType type; + try { + type = NewCodePeriodType.valueOf(typeStr.toUpperCase(Locale.US)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid type: " + typeStr); + } + validateType(type); + return type; + } + + private static void validateType(NewCodePeriodType type) { + Preconditions.checkArgument(projectCreationNCDTypes.contains(type), "Invalid type '%s'. `newCodeDefinitionType` can only be set with types: %s", + type, projectCreationNCDTypes); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/package-info.java new file mode 100644 index 00000000000..47a06a53a29 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/newcodeperiod/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.server.common.newcodeperiod; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/DefaultTemplatesResolver.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/DefaultTemplatesResolver.java new file mode 100644 index 00000000000..471d49723bf --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/DefaultTemplatesResolver.java @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.Optional; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import org.sonar.db.DbSession; + +import static java.util.Objects.requireNonNull; +import static java.util.Optional.ofNullable; + +public interface DefaultTemplatesResolver { + /** + * Resolve the effective default templates uuid for the specified {@link DefaultTemplates}. + * <ul> + * <li>{@link ResolvedDefaultTemplates#project} is always the same as {@link DefaultTemplates#projectUuid}</li> + * <li>when Governance is not installed, {@link ResolvedDefaultTemplates#application} is always {@code null}</li> + * <li>when Governance is installed, {@link ResolvedDefaultTemplates#application} is {@link DefaultTemplates#applicationsUuid} + * when it is non {@code null}, otherwise it is {@link DefaultTemplates#projectUuid}</li> + * <li>when Governance is installed, {@link ResolvedDefaultTemplates#portfolio} is {@link DefaultTemplates#portfoliosUuid} + * when it is non {@code null}, otherwise it is {@link DefaultTemplates#projectUuid}</li> + * </ul> + */ + ResolvedDefaultTemplates resolve(DbSession dbSession); + + @Immutable + final class ResolvedDefaultTemplates { + private final String project; + private final String application; + private final String portfolio; + + public ResolvedDefaultTemplates(String project, @Nullable String application, @Nullable String portfolio) { + this.project = requireNonNull(project, "project can't be null"); + this.application = application; + this.portfolio = portfolio; + } + + public String getProject() { + return project; + } + + public Optional<String> getApplication() { + return ofNullable(application); + } + + public Optional<String> getPortfolio() { + return ofNullable(portfolio); + } + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/DefaultTemplatesResolverImpl.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/DefaultTemplatesResolverImpl.java new file mode 100644 index 00000000000..ec32b6d3cf6 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/DefaultTemplatesResolverImpl.java @@ -0,0 +1,71 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.resources.ResourceType; +import org.sonar.api.resources.ResourceTypes; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.server.property.InternalProperties; + +public class DefaultTemplatesResolverImpl implements DefaultTemplatesResolver { + + private final DbClient dbClient; + private final ResourceTypes resourceTypes; + + public DefaultTemplatesResolverImpl(DbClient dbClient, ResourceTypes resourceTypes) { + this.dbClient = dbClient; + this.resourceTypes = resourceTypes; + } + + @Override + public ResolvedDefaultTemplates resolve(DbSession dbSession) { + String defaultProjectTemplate = dbClient.internalPropertiesDao().selectByKey(dbSession, InternalProperties.DEFAULT_PROJECT_TEMPLATE).orElseThrow(() -> { + throw new IllegalStateException("Default template for project is missing"); + }); + + String defaultPortfolioTemplate = null; + String defaultApplicationTemplate = null; + + if (isPortfolioEnabled(resourceTypes)) { + defaultPortfolioTemplate = dbClient.internalPropertiesDao().selectByKey(dbSession, InternalProperties.DEFAULT_PORTFOLIO_TEMPLATE).orElse(defaultProjectTemplate); + } + if (isApplicationEnabled(resourceTypes)) { + defaultApplicationTemplate = dbClient.internalPropertiesDao().selectByKey(dbSession, InternalProperties.DEFAULT_APPLICATION_TEMPLATE).orElse(defaultProjectTemplate); + } + return new ResolvedDefaultTemplates(defaultProjectTemplate, defaultApplicationTemplate, defaultPortfolioTemplate); + } + + private static boolean isPortfolioEnabled(ResourceTypes resourceTypes) { + return resourceTypes.getRoots() + .stream() + .map(ResourceType::getQualifier) + .anyMatch(Qualifiers.VIEW::equals); + } + + private static boolean isApplicationEnabled(ResourceTypes resourceTypes) { + return resourceTypes.getRoots() + .stream() + .map(ResourceType::getQualifier) + .anyMatch(Qualifiers.APP::equals); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GranteeTypeSpecificPermissionUpdater.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GranteeTypeSpecificPermissionUpdater.java new file mode 100644 index 00000000000..76a7602a91d --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GranteeTypeSpecificPermissionUpdater.java @@ -0,0 +1,32 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.Set; +import javax.annotation.Nullable; +import org.sonar.db.DbSession; + +public interface GranteeTypeSpecificPermissionUpdater<T extends PermissionChange> { + Class<T> getHandledClass(); + + Set<String> loadExistingEntityPermissions(DbSession dbSession, String uuidOfGrantee, @Nullable String entityUuid); + + boolean apply(DbSession dbSession, Set<String> existingPermissions, T change); +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GroupPermissionChange.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GroupPermissionChange.java new file mode 100644 index 00000000000..cb655361bdf --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GroupPermissionChange.java @@ -0,0 +1,53 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.Optional; +import javax.annotation.Nullable; +import org.sonar.db.entity.EntityDto; +import org.sonar.db.user.GroupDto; +import org.sonar.server.permission.GroupUuidOrAnyone; +import org.sonar.server.permission.PermissionService; + +public class GroupPermissionChange extends PermissionChange { + + private final GroupDto groupDto; + + public GroupPermissionChange(Operation operation, String permission, @Nullable EntityDto entityDto, + @Nullable GroupDto groupDto, PermissionService permissionService) { + super(operation, permission, entityDto, permissionService); + this.groupDto = groupDto; + } + + public GroupUuidOrAnyone getGroupUuidOrAnyone() { + return GroupUuidOrAnyone.from(groupDto); + } + + public Optional<String> getGroupName() { + return Optional.ofNullable(groupDto).map(GroupDto::getName); + } + + @Override + public String getUuidOfGrantee() { + return getGroupUuidOrAnyone().getUuid(); + } + + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GroupPermissionChanger.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GroupPermissionChanger.java new file mode 100644 index 00000000000..23c893ef1ae --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/GroupPermissionChanger.java @@ -0,0 +1,182 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.HashSet; +import java.util.Set; +import javax.annotation.Nullable; +import org.sonar.api.web.UserRole; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.entity.EntityDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.permission.GroupPermissionDto; +import org.sonar.server.exceptions.BadRequestException; +import org.sonar.server.permission.GroupUuidOrAnyone; + +import static com.google.common.base.Preconditions.checkNotNull; +import static java.lang.String.format; +import static org.sonar.server.common.permission.Operation.ADD; +import static org.sonar.server.common.permission.Operation.REMOVE; +import static org.sonar.server.exceptions.BadRequestException.checkRequest; + +public class GroupPermissionChanger implements GranteeTypeSpecificPermissionUpdater<GroupPermissionChange> { + + private final DbClient dbClient; + private final UuidFactory uuidFactory; + + public GroupPermissionChanger(DbClient dbClient, UuidFactory uuidFactory) { + this.dbClient = dbClient; + this.uuidFactory = uuidFactory; + } + + @Override + public Class<GroupPermissionChange> getHandledClass() { + return GroupPermissionChange.class; + } + + @Override + public Set<String> loadExistingEntityPermissions(DbSession dbSession, String uuidOfGrantee, @Nullable String entityUuid) { + if (entityUuid != null) { + return new HashSet<>(dbClient.groupPermissionDao().selectEntityPermissionsOfGroup(dbSession, uuidOfGrantee, entityUuid)); + } + return new HashSet<>(dbClient.groupPermissionDao().selectGlobalPermissionsOfGroup(dbSession, uuidOfGrantee)); + } + + @Override + public boolean apply(DbSession dbSession, Set<String> existingPermissions, GroupPermissionChange change) { + ensureConsistencyWithVisibility(change); + if (isImplicitlyAlreadyDone(change)) { + return false; + } + switch (change.getOperation()) { + case ADD: + if (existingPermissions.contains(change.getPermission())) { + return false; + } + return addPermission(dbSession, change); + case REMOVE: + if (!existingPermissions.contains(change.getPermission())) { + return false; + } + return removePermission(dbSession, change); + default: + throw new UnsupportedOperationException("Unsupported permission change: " + change.getOperation()); + } + } + + private static boolean isImplicitlyAlreadyDone(GroupPermissionChange change) { + EntityDto project = change.getEntity(); + if (project != null) { + return isImplicitlyAlreadyDone(project, change); + } + return false; + } + + private static boolean isImplicitlyAlreadyDone(EntityDto project, GroupPermissionChange change) { + return isAttemptToAddPublicPermissionToPublicComponent(change, project) + || isAttemptToRemovePermissionFromAnyoneOnPrivateComponent(change, project); + } + + private static boolean isAttemptToAddPublicPermissionToPublicComponent(GroupPermissionChange change, EntityDto project) { + return !project.isPrivate() + && change.getOperation() == ADD + && UserRole.PUBLIC_PERMISSIONS.contains(change.getPermission()); + } + + private static boolean isAttemptToRemovePermissionFromAnyoneOnPrivateComponent(GroupPermissionChange change, EntityDto project) { + return project.isPrivate() + && change.getOperation() == REMOVE + && change.getGroupUuidOrAnyone().isAnyone(); + } + + private static void ensureConsistencyWithVisibility(GroupPermissionChange change) { + EntityDto project = change.getEntity(); + if (project != null) { + checkRequest( + !isAttemptToAddPermissionToAnyoneOnPrivateComponent(change, project), + "No permission can be granted to Anyone on a private component"); + BadRequestException.checkRequest( + !isAttemptToRemovePublicPermissionFromPublicComponent(change, project), + "Permission %s can't be removed from a public component", change.getPermission()); + } + } + + private static boolean isAttemptToAddPermissionToAnyoneOnPrivateComponent(GroupPermissionChange change, EntityDto project) { + return project.isPrivate() + && change.getOperation() == ADD + && change.getGroupUuidOrAnyone().isAnyone(); + } + + private static boolean isAttemptToRemovePublicPermissionFromPublicComponent(GroupPermissionChange change, EntityDto project) { + return !project.isPrivate() + && change.getOperation() == REMOVE + && UserRole.PUBLIC_PERMISSIONS.contains(change.getPermission()); + } + + private boolean addPermission(DbSession dbSession, GroupPermissionChange change) { + validateNotAnyoneAndAdminPermission(change.getPermission(), change.getGroupUuidOrAnyone()); + + String groupUuid = change.getGroupUuidOrAnyone().getUuid(); + String groupName = change.getGroupName().orElse(null); + + GroupPermissionDto addedDto = new GroupPermissionDto() + .setUuid(uuidFactory.create()) + .setRole(change.getPermission()) + .setGroupUuid(groupUuid) + .setEntityName(change.getProjectName()) + .setEntityUuid(change.getProjectUuid()) + .setGroupName(groupName); + + dbClient.groupPermissionDao().insert(dbSession, addedDto, change.getEntity(), null); + return true; + } + + private static void validateNotAnyoneAndAdminPermission(String permission, GroupUuidOrAnyone group) { + checkRequest(!GlobalPermission.ADMINISTER.getKey().equals(permission) || !group.isAnyone(), + format("It is not possible to add the '%s' permission to group 'Anyone'.", permission)); + } + + private boolean removePermission(DbSession dbSession, GroupPermissionChange change) { + checkIfRemainingGlobalAdministrators(dbSession, change); + String groupUuid = change.getGroupUuidOrAnyone().getUuid(); + String groupName = change.getGroupName().orElse(null); + dbClient.groupPermissionDao().delete(dbSession, + change.getPermission(), + groupUuid, + groupName, + change.getEntity()); + return true; + } + + private void checkIfRemainingGlobalAdministrators(DbSession dbSession, GroupPermissionChange change) { + GroupUuidOrAnyone groupUuidOrAnyone = change.getGroupUuidOrAnyone(); + if (GlobalPermission.ADMINISTER.getKey().equals(change.getPermission()) && + !groupUuidOrAnyone.isAnyone() && + change.getProjectUuid() == null) { + String groupUuid = checkNotNull(groupUuidOrAnyone.getUuid()); + // removing global admin permission from group + int remaining = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingGroup(dbSession, GlobalPermission.ADMINISTER.getKey(), groupUuid); + checkRequest(remaining > 0, "Last group with permission '%s'. Permission cannot be removed.", GlobalPermission.ADMINISTER.getKey()); + } + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionChange.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionChange.java new file mode 100644 index 00000000000..30173c3def7 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionChange.java @@ -0,0 +1,77 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import org.sonar.db.entity.EntityDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.server.permission.PermissionService; + +import static java.util.Objects.requireNonNull; +import static org.sonar.server.exceptions.BadRequestException.checkRequest; + +public abstract class PermissionChange { + + private final Operation operation; + private final String permission; + private final EntityDto entity; + protected final PermissionService permissionService; + + protected PermissionChange(Operation operation, String permission, @Nullable EntityDto entity, PermissionService permissionService) { + this.operation = requireNonNull(operation); + this.permission = requireNonNull(permission); + this.entity = entity; + this.permissionService = permissionService; + if (entity == null) { + checkRequest(permissionService.getGlobalPermissions().stream().anyMatch(p -> p.getKey().equals(permission)), + "Invalid global permission '%s'. Valid values are %s", permission, + permissionService.getGlobalPermissions().stream().map(GlobalPermission::getKey).toList()); + } else { + checkRequest(permissionService.getAllProjectPermissions().contains(permission), "Invalid project permission '%s'. Valid values are %s", permission, + permissionService.getAllProjectPermissions()); + } + } + + public Operation getOperation() { + return operation; + } + + public String getPermission() { + return permission; + } + + @CheckForNull + public EntityDto getEntity() { + return entity; + } + + @CheckForNull + public String getProjectName() { + return entity == null ? null : entity.getName(); + } + + @CheckForNull + public String getProjectUuid() { + return entity == null ? null : entity.getUuid(); + } + + public abstract String getUuidOfGrantee(); +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionTemplateService.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionTemplateService.java new file mode 100644 index 00000000000..3857d0c3f9c --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionTemplateService.java @@ -0,0 +1,243 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.server.ServerSide; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.entity.EntityDto; +import org.sonar.db.permission.GroupPermissionDto; +import org.sonar.db.permission.UserPermissionDto; +import org.sonar.db.permission.template.PermissionTemplateCharacteristicDto; +import org.sonar.db.permission.template.PermissionTemplateDto; +import org.sonar.db.permission.template.PermissionTemplateGroupDto; +import org.sonar.db.permission.template.PermissionTemplateUserDto; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.user.UserDto; +import org.sonar.db.user.UserId; +import org.sonar.server.es.Indexers; +import org.sonar.server.exceptions.TemplateMatchingKeyException; +import org.sonar.server.user.UserSession; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.String.format; +import static java.util.Collections.singletonList; +import static org.sonar.api.security.DefaultGroups.isAnyone; +import static org.sonar.api.web.UserRole.PUBLIC_PERMISSIONS; +import static org.sonar.db.permission.GlobalPermission.SCAN; + +@ServerSide +public class PermissionTemplateService { + + private final DbClient dbClient; + private final Indexers indexers; + private final UserSession userSession; + private final DefaultTemplatesResolver defaultTemplatesResolver; + private final UuidFactory uuidFactory; + + public PermissionTemplateService(DbClient dbClient, Indexers indexers, UserSession userSession, + DefaultTemplatesResolver defaultTemplatesResolver, UuidFactory uuidFactory) { + this.dbClient = dbClient; + this.indexers = indexers; + this.userSession = userSession; + this.defaultTemplatesResolver = defaultTemplatesResolver; + this.uuidFactory = uuidFactory; + } + + public boolean wouldUserHaveScanPermissionWithDefaultTemplate(DbSession dbSession, @Nullable String userUuid, String projectKey) { + if (userSession.hasPermission(SCAN)) { + return true; + } + + ProjectDto projectDto = new ProjectDto().setKey(projectKey).setQualifier(Qualifiers.PROJECT); + PermissionTemplateDto template = findTemplate(dbSession, projectDto); + if (template == null) { + return false; + } + + List<String> potentialPermissions = dbClient.permissionTemplateDao().selectPotentialPermissionsByUserUuidAndTemplateUuid(dbSession, userUuid, template.getUuid()); + return potentialPermissions.contains(SCAN.getKey()); + } + + /** + * Apply a permission template to a set of projects. Authorization to administrate these projects + * is not verified. The projects must exist, so the "project creator" permissions defined in the + * template are ignored. + */ + public void applyAndCommit(DbSession dbSession, PermissionTemplateDto template, Collection<EntityDto> entities) { + if (entities.isEmpty()) { + return; + } + + for (EntityDto entity : entities) { + dbClient.groupPermissionDao().deleteByEntityUuid(dbSession, entity); + dbClient.userPermissionDao().deleteEntityPermissions(dbSession, entity); + copyPermissions(dbSession, template, entity, null); + } + indexers.commitAndIndexEntities(dbSession, entities, Indexers.EntityEvent.PERMISSION_CHANGE); + } + + /** + * Apply the default permission template to a new project (has no permissions yet). + * + * @param projectCreatorUserId id of the user creating the project. + */ + public void applyDefaultToNewComponent(DbSession dbSession, EntityDto entityDto, @Nullable String projectCreatorUserId) { + PermissionTemplateDto template = findTemplate(dbSession, entityDto); + checkArgument(template != null, "Cannot retrieve default permission template"); + copyPermissions(dbSession, template, entityDto, projectCreatorUserId); + } + + public boolean hasDefaultTemplateWithPermissionOnProjectCreator(DbSession dbSession, ProjectDto projectDto) { + PermissionTemplateDto template = findTemplate(dbSession, projectDto); + return hasProjectCreatorPermission(dbSession, template); + } + + private boolean hasProjectCreatorPermission(DbSession dbSession, @Nullable PermissionTemplateDto template) { + return template != null && dbClient.permissionTemplateCharacteristicDao().selectByTemplateUuids(dbSession, singletonList(template.getUuid())).stream() + .anyMatch(PermissionTemplateCharacteristicDto::getWithProjectCreator); + } + + private void copyPermissions(DbSession dbSession, PermissionTemplateDto template, EntityDto entity, @Nullable String projectCreatorUserUuid) { + List<PermissionTemplateUserDto> usersPermissions = dbClient.permissionTemplateDao().selectUserPermissionsByTemplateId(dbSession, template.getUuid()); + Set<String> permissionTemplateUserUuids = usersPermissions.stream().map(PermissionTemplateUserDto::getUserUuid).collect(Collectors.toSet()); + Map<String, UserId> userIdByUuid = dbClient.userDao().selectByUuids(dbSession, permissionTemplateUserUuids).stream().collect(Collectors.toMap(UserDto::getUuid, u -> u)); + usersPermissions + .stream() + .filter(up -> permissionValidForProject(entity.isPrivate(), up.getPermission())) + .forEach(up -> { + UserPermissionDto dto = new UserPermissionDto(uuidFactory.create(), up.getPermission(), up.getUserUuid(), entity.getUuid()); + dbClient.userPermissionDao().insert(dbSession, dto, entity, userIdByUuid.get(up.getUserUuid()), template); + }); + + List<PermissionTemplateGroupDto> groupsPermissions = dbClient.permissionTemplateDao().selectGroupPermissionsByTemplateUuid(dbSession, template.getUuid()); + groupsPermissions + .stream() + .filter(gp -> groupNameValidForProject(entity.isPrivate(), gp.getGroupName())) + .filter(gp -> permissionValidForProject(entity.isPrivate(), gp.getPermission())) + .forEach(gp -> { + String groupUuid = isAnyone(gp.getGroupName()) ? null : gp.getGroupUuid(); + String groupName = groupUuid == null ? null : dbClient.groupDao().selectByUuid(dbSession, groupUuid).getName(); + GroupPermissionDto dto = new GroupPermissionDto() + .setUuid(uuidFactory.create()) + .setGroupUuid(groupUuid) + .setGroupName(groupName) + .setRole(gp.getPermission()) + .setEntityUuid(entity.getUuid()) + .setEntityName(entity.getName()); + + dbClient.groupPermissionDao().insert(dbSession, dto, entity, template); + }); + + List<PermissionTemplateCharacteristicDto> characteristics = dbClient.permissionTemplateCharacteristicDao().selectByTemplateUuids(dbSession, singletonList(template.getUuid())); + if (projectCreatorUserUuid != null) { + Set<String> permissionsForCurrentUserAlreadyInDb = usersPermissions.stream() + .filter(userPermission -> projectCreatorUserUuid.equals(userPermission.getUserUuid())) + .map(PermissionTemplateUserDto::getPermission) + .collect(java.util.stream.Collectors.toSet()); + + UserDto userDto = dbClient.userDao().selectByUuid(dbSession, projectCreatorUserUuid); + characteristics.stream() + .filter(PermissionTemplateCharacteristicDto::getWithProjectCreator) + .filter(up -> permissionValidForProject(entity.isPrivate(), up.getPermission())) + .filter(characteristic -> !permissionsForCurrentUserAlreadyInDb.contains(characteristic.getPermission())) + .forEach(c -> { + UserPermissionDto dto = new UserPermissionDto(uuidFactory.create(), c.getPermission(), userDto.getUuid(), entity.getUuid()); + dbClient.userPermissionDao().insert(dbSession, dto, entity, userDto, template); + }); + } + } + + private static boolean permissionValidForProject(boolean isPrivateEntity, String permission) { + return isPrivateEntity || !PUBLIC_PERMISSIONS.contains(permission); + } + + private static boolean groupNameValidForProject(boolean isPrivateEntity, String groupName) { + return !isPrivateEntity || !isAnyone(groupName); + } + + /** + * Return the permission template for the given component. If no template key pattern match then consider default + * template for the component qualifier. + */ + @CheckForNull + private PermissionTemplateDto findTemplate(DbSession dbSession, EntityDto entityDto) { + List<PermissionTemplateDto> allPermissionTemplates = dbClient.permissionTemplateDao().selectAll(dbSession, null); + List<PermissionTemplateDto> matchingTemplates = new ArrayList<>(); + for (PermissionTemplateDto permissionTemplateDto : allPermissionTemplates) { + String keyPattern = permissionTemplateDto.getKeyPattern(); + if (StringUtils.isNotBlank(keyPattern) && entityDto.getKey().matches(keyPattern)) { + matchingTemplates.add(permissionTemplateDto); + } + } + checkAtMostOneMatchForComponentKey(entityDto.getKey(), matchingTemplates); + if (matchingTemplates.size() == 1) { + return matchingTemplates.get(0); + } + + String qualifier = entityDto.getQualifier(); + DefaultTemplatesResolver.ResolvedDefaultTemplates resolvedDefaultTemplates = defaultTemplatesResolver.resolve(dbSession); + switch (qualifier) { + case Qualifiers.PROJECT: + return dbClient.permissionTemplateDao().selectByUuid(dbSession, resolvedDefaultTemplates.getProject()); + case Qualifiers.VIEW: + String portDefaultTemplateUuid = resolvedDefaultTemplates.getPortfolio().orElseThrow( + () -> new IllegalStateException("Failed to find default template for portfolios")); + return dbClient.permissionTemplateDao().selectByUuid(dbSession, portDefaultTemplateUuid); + case Qualifiers.APP: + String appDefaultTemplateUuid = resolvedDefaultTemplates.getApplication().orElseThrow( + () -> new IllegalStateException("Failed to find default template for applications")); + return dbClient.permissionTemplateDao().selectByUuid(dbSession, appDefaultTemplateUuid); + default: + throw new IllegalArgumentException(format("Qualifier '%s' is not supported", qualifier)); + } + } + + private static void checkAtMostOneMatchForComponentKey(String componentKey, List<PermissionTemplateDto> matchingTemplates) { + if (matchingTemplates.size() > 1) { + StringBuilder templatesNames = new StringBuilder(); + for (Iterator<PermissionTemplateDto> it = matchingTemplates.iterator(); it.hasNext(); ) { + templatesNames.append("\"").append(it.next().getName()).append("\""); + if (it.hasNext()) { + templatesNames.append(", "); + } + } + throw new TemplateMatchingKeyException(MessageFormat.format( + "The \"{0}\" key matches multiple permission templates: {1}." + + " A system administrator must update these templates so that only one of them matches the key.", + componentKey, + templatesNames.toString())); + } + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionUpdater.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionUpdater.java new file mode 100644 index 00000000000..e2beb971b46 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/PermissionUpdater.java @@ -0,0 +1,78 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import org.sonar.db.DbSession; +import org.sonar.db.entity.EntityDto; +import org.sonar.server.es.Indexers; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toMap; +import static org.sonar.api.utils.Preconditions.checkState; +import static org.sonar.server.es.Indexers.EntityEvent.PERMISSION_CHANGE; + +public class PermissionUpdater<T extends PermissionChange> { + + private final Indexers indexers; + + private final Map<Class<?>, GranteeTypeSpecificPermissionUpdater<T>> specificPermissionClassToHandler; + + public PermissionUpdater(Indexers indexers, Set<GranteeTypeSpecificPermissionUpdater<T>> permissionChangers) { + this.indexers = indexers; + specificPermissionClassToHandler = permissionChangers.stream() + .collect(toMap(GranteeTypeSpecificPermissionUpdater::getHandledClass, Function.identity())); + } + + public void apply(DbSession dbSession, Collection<T> changes) { + checkState(changes.stream().map(PermissionChange::getProjectUuid).distinct().count() <= 1, + "Only one project per changes is supported"); + + List<String> projectOrViewUuids = new ArrayList<>(); + Map<Optional<String>, List<T>> granteeUuidToPermissionChanges = changes.stream().collect(groupingBy(change -> Optional.ofNullable(change.getUuidOfGrantee()))); + granteeUuidToPermissionChanges.values().forEach(permissionChanges -> applyForSingleGrantee(dbSession, projectOrViewUuids, permissionChanges)); + + indexers.commitAndIndexOnEntityEvent(dbSession, projectOrViewUuids, PERMISSION_CHANGE); + } + + private void applyForSingleGrantee(DbSession dbSession, List<String> projectOrViewUuids, List<T> permissionChanges) { + T anyPermissionChange = permissionChanges.iterator().next(); + EntityDto entity = anyPermissionChange.getEntity(); + String entityUuid = Optional.ofNullable(entity).map(EntityDto::getUuid).orElse(null); + GranteeTypeSpecificPermissionUpdater<T> granteeTypeSpecificPermissionUpdater = getSpecificProjectUpdater(anyPermissionChange); + Set<String> existingPermissions = granteeTypeSpecificPermissionUpdater.loadExistingEntityPermissions(dbSession, anyPermissionChange.getUuidOfGrantee(), entityUuid); + for (T permissionChange : permissionChanges) { + if (granteeTypeSpecificPermissionUpdater.apply(dbSession, existingPermissions, permissionChange) && permissionChange.getProjectUuid() != null) { + projectOrViewUuids.add(permissionChange.getProjectUuid()); + } + } + } + + private GranteeTypeSpecificPermissionUpdater<T> getSpecificProjectUpdater(T anyPermissionChange) { + return specificPermissionClassToHandler.get(anyPermissionChange.getClass()); + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/UserPermissionChange.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/UserPermissionChange.java new file mode 100644 index 00000000000..acb5c260065 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/UserPermissionChange.java @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import javax.annotation.Nullable; +import org.sonar.db.entity.EntityDto; +import org.sonar.db.user.UserId; +import org.sonar.server.common.permission.Operation; +import org.sonar.server.permission.PermissionService; + +import static java.util.Objects.requireNonNull; + +public class UserPermissionChange extends PermissionChange { + + private final UserId userId; + + public UserPermissionChange(Operation operation, String permission, @Nullable EntityDto entity, UserId userId, + PermissionService permissionService) { + super(operation, permission, entity, permissionService); + this.userId = requireNonNull(userId); + } + + public UserId getUserId() { + return userId; + } + + @Override + public String getUuidOfGrantee() { + return userId.getUuid(); + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/UserPermissionChanger.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/UserPermissionChanger.java new file mode 100644 index 00000000000..3874f7b1f48 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/permission/UserPermissionChanger.java @@ -0,0 +1,142 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.permission; + +import java.util.HashSet; +import java.util.Set; +import org.jetbrains.annotations.Nullable; +import org.sonar.api.web.UserRole; +import org.sonar.core.util.UuidFactory; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.entity.EntityDto; +import org.sonar.db.permission.GlobalPermission; +import org.sonar.db.permission.UserPermissionDto; + +import static org.sonar.server.common.permission.Operation.ADD; +import static org.sonar.server.common.permission.Operation.REMOVE; +import static org.sonar.server.exceptions.BadRequestException.checkRequest; + +/** + * Adds and removes user permissions. Both global and project scopes are supported. + */ +public class UserPermissionChanger implements GranteeTypeSpecificPermissionUpdater<UserPermissionChange> { + + private final DbClient dbClient; + private final UuidFactory uuidFactory; + + public UserPermissionChanger(DbClient dbClient, UuidFactory uuidFactory) { + this.dbClient = dbClient; + this.uuidFactory = uuidFactory; + } + + @Override + public Class<UserPermissionChange> getHandledClass() { + return UserPermissionChange.class; + } + + @Override + public Set<String> loadExistingEntityPermissions(DbSession dbSession, String uuidOfGrantee, @Nullable String entityUuid) { + if (entityUuid != null) { + return new HashSet<>(dbClient.userPermissionDao().selectEntityPermissionsOfUser(dbSession, uuidOfGrantee, entityUuid)); + } + return new HashSet<>(dbClient.userPermissionDao().selectGlobalPermissionsOfUser(dbSession, uuidOfGrantee)); + } + + @Override + public boolean apply(DbSession dbSession, Set<String> existingPermissions, UserPermissionChange change) { + ensureConsistencyWithVisibility(change); + if (isImplicitlyAlreadyDone(change)) { + return false; + } + switch (change.getOperation()) { + case ADD: + return addPermission(dbSession, existingPermissions, change); + case REMOVE: + return removePermission(dbSession, existingPermissions, change); + default: + throw new UnsupportedOperationException("Unsupported permission change: " + change.getOperation()); + } + } + + private static boolean isImplicitlyAlreadyDone(UserPermissionChange change) { + EntityDto project = change.getEntity(); + if (project != null) { + return isImplicitlyAlreadyDone(project, change); + } + return false; + } + + private static boolean isImplicitlyAlreadyDone(EntityDto project, UserPermissionChange change) { + return isAttemptToAddPublicPermissionToPublicComponent(change, project); + } + + private static boolean isAttemptToAddPublicPermissionToPublicComponent(UserPermissionChange change, EntityDto project) { + return !project.isPrivate() + && change.getOperation() == ADD + && UserRole.PUBLIC_PERMISSIONS.contains(change.getPermission()); + } + + private static void ensureConsistencyWithVisibility(UserPermissionChange change) { + EntityDto project = change.getEntity(); + if (project != null) { + checkRequest(!isAttemptToRemovePublicPermissionFromPublicComponent(change, project), + "Permission %s can't be removed from a public component", change.getPermission()); + } + } + + private static boolean isAttemptToRemovePublicPermissionFromPublicComponent(UserPermissionChange change, EntityDto entity) { + return !entity.isPrivate() + && change.getOperation() == REMOVE + && UserRole.PUBLIC_PERMISSIONS.contains(change.getPermission()); + } + + private boolean addPermission(DbSession dbSession, Set<String> existingPermissions, UserPermissionChange change) { + if (existingPermissions.contains(change.getPermission())) { + return false; + } + UserPermissionDto dto = new UserPermissionDto(uuidFactory.create(), change.getPermission(), change.getUserId().getUuid(), + change.getProjectUuid()); + dbClient.userPermissionDao().insert(dbSession, dto, change.getEntity(), change.getUserId(), null); + return true; + } + + private boolean removePermission(DbSession dbSession, Set<String> existingPermissions, UserPermissionChange change) { + if (!existingPermissions.contains(change.getPermission())) { + return false; + } + checkOtherAdminsExist(dbSession, change); + EntityDto entity = change.getEntity(); + if (entity != null) { + dbClient.userPermissionDao().deleteEntityPermission(dbSession, change.getUserId(), change.getPermission(), entity); + } else { + dbClient.userPermissionDao().deleteGlobalPermission(dbSession, change.getUserId(), change.getPermission()); + } + return true; + } + + private void checkOtherAdminsExist(DbSession dbSession, UserPermissionChange change) { + if (GlobalPermission.ADMINISTER.getKey().equals(change.getPermission()) && change.getProjectUuid() == null) { + int remaining = dbClient.authorizationDao().countUsersWithGlobalPermissionExcludingUserPermission(dbSession, change.getPermission(), change.getUserId().getUuid()); + checkRequest(remaining > 0, "Last user with permission '%s'. Permission cannot be removed.", GlobalPermission.ADMINISTER.getKey()); + } + } + +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/project/ProjectCreator.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/project/ProjectCreator.java new file mode 100644 index 00000000000..22cf07233f4 --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/project/ProjectCreator.java @@ -0,0 +1,71 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.project; + +import javax.annotation.Nullable; +import org.sonar.api.server.ServerSide; +import org.sonar.db.DbSession; +import org.sonar.db.project.CreationMethod; +import org.sonar.server.common.component.ComponentCreationParameters; +import org.sonar.server.common.component.ComponentUpdater; +import org.sonar.server.common.component.NewComponent; +import org.sonar.server.component.ComponentCreationData; +import org.sonar.server.project.ProjectDefaultVisibility; +import org.sonar.server.user.UserSession; + +import static org.sonar.api.resources.Qualifiers.PROJECT; + +@ServerSide +public class ProjectCreator { + + private final UserSession userSession; + private final ProjectDefaultVisibility projectDefaultVisibility; + private final ComponentUpdater componentUpdater; + + public ProjectCreator(UserSession userSession, ProjectDefaultVisibility projectDefaultVisibility, ComponentUpdater componentUpdater) { + this.userSession = userSession; + this.projectDefaultVisibility = projectDefaultVisibility; + this.componentUpdater = componentUpdater; + } + + public ComponentCreationData createProject(DbSession dbSession, String projectKey, String projectName, @Nullable String mainBranchName, CreationMethod creationMethod, + @Nullable Boolean isPrivate, boolean isManaged) { + boolean visibility = isPrivate != null ? isPrivate : projectDefaultVisibility.get(dbSession).isPrivate(); + NewComponent projectComponent = NewComponent.newComponentBuilder() + .setKey(projectKey) + .setName(projectName) + .setPrivate(visibility) + .setQualifier(PROJECT) + .build(); + ComponentCreationParameters componentCreationParameters = ComponentCreationParameters.builder() + .newComponent(projectComponent) + .userLogin(userSession.getLogin()) + .userUuid(userSession.getUuid()) + .mainBranchName(mainBranchName) + .isManaged(isManaged) + .creationMethod(creationMethod) + .build(); + return componentUpdater.createWithoutCommit(dbSession, componentCreationParameters); + } + + public ComponentCreationData createProject(DbSession dbSession, String projectKey, String projectName, @Nullable String mainBranchName, CreationMethod creationMethod) { + return createProject(dbSession, projectKey, projectName, mainBranchName, creationMethod, projectDefaultVisibility.get(dbSession).isPrivate(), false); + } +} diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/project/package-info.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/project/package-info.java new file mode 100644 index 00000000000..174ee7fd7cb --- /dev/null +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/project/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.server.common.project; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almintegration/ProjectKeyGeneratorTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almintegration/ProjectKeyGeneratorTest.java new file mode 100644 index 00000000000..3751d757bda --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almintegration/ProjectKeyGeneratorTest.java @@ -0,0 +1,92 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almintegration; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.core.util.UuidFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.sonar.server.common.almintegration.ProjectKeyGenerator.MAX_PROJECT_KEY_SIZE; +import static org.sonar.server.common.almintegration.ProjectKeyGenerator.PROJECT_KEY_SEPARATOR; + +@RunWith(MockitoJUnitRunner.class) +public class ProjectKeyGeneratorTest { + + private static final int MAX_UUID_SIZE = 40; + private static final String UUID_STRING = RandomStringUtils.randomAlphanumeric(MAX_UUID_SIZE); + + @Mock + private UuidFactory uuidFactory; + + @InjectMocks + private ProjectKeyGenerator projectKeyGenerator; + + @Before + public void setUp() { + when(uuidFactory.create()).thenReturn(UUID_STRING); + } + + @Test + public void generateUniqueProjectKey_shortProjectName_shouldAppendUuid() { + String fullProjectName = RandomStringUtils.randomAlphanumeric(10); + + assertThat(projectKeyGenerator.generateUniqueProjectKey(fullProjectName)) + .isEqualTo(generateExpectedKeyName(fullProjectName)); + } + + @Test + public void generateUniqueProjectKey_projectNameEqualsToMaximumSize_shouldTruncateProjectNameAndPreserveUUID() { + String fullProjectName = RandomStringUtils.randomAlphanumeric(MAX_PROJECT_KEY_SIZE); + + String projectKey = projectKeyGenerator.generateUniqueProjectKey(fullProjectName); + assertThat(projectKey) + .hasSize(MAX_PROJECT_KEY_SIZE) + .isEqualTo(generateExpectedKeyName(fullProjectName.substring(fullProjectName.length() + UUID_STRING.length() + 1 - MAX_PROJECT_KEY_SIZE))); + } + + @Test + public void generateUniqueProjectKey_projectNameBiggerThanMaximumSize_shouldTruncateProjectNameAndPreserveUUID() { + String fullProjectName = RandomStringUtils.randomAlphanumeric(MAX_PROJECT_KEY_SIZE + 50); + + String projectKey = projectKeyGenerator.generateUniqueProjectKey(fullProjectName); + assertThat(projectKey) + .hasSize(MAX_PROJECT_KEY_SIZE) + .isEqualTo(generateExpectedKeyName(fullProjectName.substring(fullProjectName.length() + UUID_STRING.length() + 1 - MAX_PROJECT_KEY_SIZE))); + } + + @Test + public void generateUniqueProjectKey_projectNameContainsSlashes_shouldBeEscaped() { + String fullProjectName = "a/b/c"; + + assertThat(projectKeyGenerator.generateUniqueProjectKey(fullProjectName)) + .isEqualTo(generateExpectedKeyName(fullProjectName.replace("/", "_"))); + } + + private String generateExpectedKeyName(String truncatedProjectName) { + return truncatedProjectName + PROJECT_KEY_SEPARATOR + UUID_STRING; + } +} diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/DelegatingDevOpsProjectCreatorFactoryTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/DelegatingDevOpsProjectCreatorFactoryTest.java new file mode 100644 index 00000000000..b8815607956 --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/DelegatingDevOpsProjectCreatorFactoryTest.java @@ -0,0 +1,63 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.Test; +import org.sonar.db.DbSession; + +import static java.util.Collections.emptySet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DelegatingDevOpsProjectCreatorFactoryTest { + + private static final DbSession DB_SESSION = mock(); + private static final Map<String, String> CHARACTERISTICS = Map.of("toto", "tata"); + + @Test + public void getDevOpsProjectDescriptor_whenNoDelegates_shouldReturnEmptyOptional() { + DelegatingDevOpsProjectCreatorFactory noDelegates = new DelegatingDevOpsProjectCreatorFactory(emptySet()); + Optional<DevOpsProjectCreator> devOpsProjectCreator = noDelegates.getDevOpsProjectCreator(DB_SESSION, CHARACTERISTICS); + assertThat(devOpsProjectCreator).isEmpty(); + } + + @Test + public void getDevOpsProjectDescriptor_whenNoDelegatesReturningACreator_shouldReturnEmptyOptional() { + DelegatingDevOpsProjectCreatorFactory delegates = new DelegatingDevOpsProjectCreatorFactory(Set.of(mock(), mock())); + Optional<DevOpsProjectCreator> devOpsProjectCreator = delegates.getDevOpsProjectCreator(DB_SESSION, CHARACTERISTICS); + + assertThat(devOpsProjectCreator).isEmpty(); + } + + @Test + public void getDevOpsProjectDescriptor_whenOneDelegatesReturningACreator_shouldDelegate() { + DevOpsProjectCreatorFactory successfulDelegate = mock(); + DevOpsProjectCreator devOpsProjectCreator = mock(); + when(successfulDelegate.getDevOpsProjectCreator(DB_SESSION, CHARACTERISTICS)).thenReturn(Optional.of(devOpsProjectCreator)); + DelegatingDevOpsProjectCreatorFactory delegates = new DelegatingDevOpsProjectCreatorFactory(Set.of(mock(), successfulDelegate)); + + assertThat(delegates.getDevOpsProjectCreator(DB_SESSION, CHARACTERISTICS)).contains(devOpsProjectCreator); + } + +} diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorFactoryTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorFactoryTest.java new file mode 100644 index 00000000000..ac805ea74ab --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorFactoryTest.java @@ -0,0 +1,269 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.github; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.sonar.alm.client.github.GithubGlobalSettingsValidator; +import org.sonar.alm.client.github.GithubPermissionConverter; +import org.sonar.auth.github.AppInstallationToken; +import org.sonar.auth.github.GitHubSettings; +import org.sonar.auth.github.client.GithubApplicationClient; +import org.sonar.auth.github.security.AccessToken; +import org.sonar.auth.github.security.UserAccessToken; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.common.almintegration.ProjectKeyGenerator; +import org.sonar.server.common.almsettings.DevOpsProjectCreator; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.common.almsettings.github.GithubProjectCreationParameters; +import org.sonar.server.common.almsettings.github.GithubProjectCreator; +import org.sonar.server.common.almsettings.github.GithubProjectCreatorFactory; +import org.sonar.server.exceptions.BadConfigurationException; +import org.sonar.server.management.ManagedProjectService; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.common.permission.PermissionUpdater; +import org.sonar.server.common.permission.UserPermissionChange; +import org.sonar.server.project.ProjectDefaultVisibility; +import org.sonar.server.common.project.ProjectCreator; +import org.sonar.server.user.UserSession; + +import static java.lang.String.format; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.core.ce.CeTaskCharacteristics.DEVOPS_PLATFORM_PROJECT_IDENTIFIER; +import static org.sonar.core.ce.CeTaskCharacteristics.DEVOPS_PLATFORM_URL; + +@RunWith(MockitoJUnitRunner.class) +public class GithubProjectCreatorFactoryTest { + private static final String PROJECT_NAME = "projectName"; + private static final String ORGANIZATION_NAME = "orgname"; + private static final String GITHUB_REPO_FULL_NAME = ORGANIZATION_NAME + "/" + PROJECT_NAME; + private static final String GITHUB_API_URL = "https://api.toto.com"; + + private static final DevOpsProjectDescriptor GITHUB_PROJECT_DESCRIPTOR = new DevOpsProjectDescriptor(ALM.GITHUB, GITHUB_API_URL, GITHUB_REPO_FULL_NAME); + private static final Map<String, String> VALID_GITHUB_PROJECT_COORDINATES = Map.of( + DEVOPS_PLATFORM_URL, GITHUB_PROJECT_DESCRIPTOR.url(), + DEVOPS_PLATFORM_PROJECT_IDENTIFIER, GITHUB_PROJECT_DESCRIPTOR.projectIdentifier()); + private static final long APP_INSTALLATION_ID = 534534534543L; + private static final String USER_ACCESS_TOKEN = "userPat"; + + @Mock + private DbSession dbSession; + @Mock + private GithubGlobalSettingsValidator githubGlobalSettingsValidator; + @Mock + private GithubApplicationClient githubApplicationClient; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private DbClient dbClient; + @Mock + private UserSession userSession; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ProjectDefaultVisibility projectDefaultVisibility; + @Mock + private ProjectKeyGenerator projectKeyGenerator; + @Mock + private GitHubSettings gitHubSettings; + @Mock + private GithubPermissionConverter githubPermissionConverter; + @Mock + private AppInstallationToken appInstallationToken; + @Mock + private AppInstallationToken authAppInstallationToken; + @Mock + private PermissionService permissionService; + @Mock + private PermissionUpdater<UserPermissionChange> permissionUpdater; + @Mock + private ManagedProjectService managedProjectService; + @Mock + private ProjectCreator projectCreator; + + @InjectMocks + private GithubProjectCreatorFactory githubProjectCreatorFactory; + + @Test + public void getDevOpsProjectCreator_whenNoCharacteristics_shouldReturnEmpty() { + Optional<DevOpsProjectCreator> devOpsProjectCreator = githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, Map.of()); + + assertThat(devOpsProjectCreator).isEmpty(); + } + + @Test + public void getDevOpsProjectCreator_whenValidCharacteristicsButNoAlmSettingDao_shouldReturnEmpty() { + Optional<DevOpsProjectCreator> devOpsProjectCreator = githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, VALID_GITHUB_PROJECT_COORDINATES); + assertThat(devOpsProjectCreator).isEmpty(); + } + + @Test + public void getDevOpsProjectCreator_whenValidCharacteristicsButInvalidAlmSettingDto_shouldThrow() { + AlmSettingDto almSettingDto = mockAlmSettingDto(true); + IllegalArgumentException error = new IllegalArgumentException("error happened"); + when(githubGlobalSettingsValidator.validate(almSettingDto)).thenThrow(error); + + assertThatIllegalArgumentException().isThrownBy(() -> githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, VALID_GITHUB_PROJECT_COORDINATES)) + .isSameAs(error); + } + + @Test + public void getDevOpsProjectCreator_whenAppHasNoAccessToRepo_shouldReturnEmpty() { + mockAlmSettingDto(true); + when(githubApplicationClient.getInstallationId(any(), eq(GITHUB_REPO_FULL_NAME))).thenReturn(Optional.empty()); + + Optional<DevOpsProjectCreator> devOpsProjectCreator = githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, VALID_GITHUB_PROJECT_COORDINATES); + assertThat(devOpsProjectCreator).isEmpty(); + } + + @Test + public void getDevOpsProjectCreator_whenNotPossibleToGenerateToken_shouldThrow() { + AlmSettingDto almSettingDto = mockAlmSettingDto(true); + when(githubApplicationClient.getInstallationId(any(), eq(GITHUB_REPO_FULL_NAME))).thenReturn(Optional.of(APP_INSTALLATION_ID)); + when(githubGlobalSettingsValidator.validate(almSettingDto)).thenReturn(mock()); + when(githubApplicationClient.createAppInstallationToken(any(), eq(APP_INSTALLATION_ID))).thenReturn(Optional.empty()); + + assertThatIllegalStateException().isThrownBy(() -> githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, VALID_GITHUB_PROJECT_COORDINATES)) + .withMessage("Error while generating token for GitHub Api Url null (installation id: 534534534543)"); + } + + @Test + public void getDevOpsProjectCreator_whenOneValidAlmSetting_shouldInstantiateDevOpsProjectCreator() { + AlmSettingDto almSettingDto = mockAlmSettingDto(true); + mockSuccessfulGithubInteraction(); + + DevOpsProjectCreator devOpsProjectCreator = githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, VALID_GITHUB_PROJECT_COORDINATES).orElseThrow(); + + GithubProjectCreator expectedGithubProjectCreator = getExpectedGithubProjectCreator(almSettingDto, false, appInstallationToken); + assertThat(devOpsProjectCreator).usingRecursiveComparison().isEqualTo(expectedGithubProjectCreator); + } + + @Test + public void getDevOpsProjectCreator_whenOneValidAlmSettingAndPublicByDefaultAndAutoProvisioningEnabled_shouldInstantiateDevOpsProjectCreatorAndDefineAnAuthAppToken() { + AlmSettingDto almSettingDto = mockAlmSettingDto(true); + mockSuccessfulGithubInteraction(); + + when(projectDefaultVisibility.get(any()).isPrivate()).thenReturn(true); + mockValidGitHubSettings(); + + long authAppInstallationId = 32; + when(githubApplicationClient.getInstallationId(any(), eq(GITHUB_REPO_FULL_NAME))).thenReturn(Optional.of(authAppInstallationId)); + when(githubApplicationClient.createAppInstallationToken(any(), eq(authAppInstallationId))).thenReturn(Optional.of(authAppInstallationToken)); + + DevOpsProjectCreator devOpsProjectCreator = githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, VALID_GITHUB_PROJECT_COORDINATES).orElseThrow(); + + GithubProjectCreator expectedGithubProjectCreator = getExpectedGithubProjectCreator(almSettingDto, true, appInstallationToken); + assertThat(devOpsProjectCreator).usingRecursiveComparison().isEqualTo(expectedGithubProjectCreator); + } + + @Test + public void getDevOpsProjectCreator_whenOneMatchingAndOneNotMatchingAlmSetting_shouldInstantiateDevOpsProjectCreator() { + AlmSettingDto matchingAlmSettingDto = mockAlmSettingDto(true); + AlmSettingDto notMatchingAlmSettingDto = mockAlmSettingDto(false); + when(dbClient.almSettingDao().selectByAlm(dbSession, ALM.GITHUB)).thenReturn(List.of(notMatchingAlmSettingDto, matchingAlmSettingDto)); + + mockSuccessfulGithubInteraction(); + + DevOpsProjectCreator devOpsProjectCreator = githubProjectCreatorFactory.getDevOpsProjectCreator(dbSession, VALID_GITHUB_PROJECT_COORDINATES).orElseThrow(); + + GithubProjectCreator expectedGithubProjectCreator = getExpectedGithubProjectCreator(matchingAlmSettingDto, false, appInstallationToken); + assertThat(devOpsProjectCreator).usingRecursiveComparison().isEqualTo(expectedGithubProjectCreator); + } + + @Test + public void getDevOpsProjectCreatorFromImport_shouldInstantiateDevOpsProjectCreator() { + AlmSettingDto mockAlmSettingDto = mockAlmSettingDto(true); + mockAlmPatDto(mockAlmSettingDto); + + mockSuccessfulGithubInteraction(); + + DevOpsProjectCreator devOpsProjectCreator = githubProjectCreatorFactory.getDevOpsProjectCreator(mockAlmSettingDto, GITHUB_PROJECT_DESCRIPTOR).orElseThrow(); + + GithubProjectCreator expectedGithubProjectCreator = getExpectedGithubProjectCreator(mockAlmSettingDto, false, new UserAccessToken(USER_ACCESS_TOKEN)); + assertThat(devOpsProjectCreator).usingRecursiveComparison().isEqualTo(expectedGithubProjectCreator); + } + + @Test + public void getDevOpsProjectCreatorFromImport_whenGitHubConfigDoesNotAllowAccessToRepo_shouldThrow() { + AlmSettingDto mockAlmSettingDto = mockAlmSettingDto(false); + mockAlmPatDto(mockAlmSettingDto); + + mockValidGitHubSettings(); + + when(githubApplicationClient.getInstallationId(any(), eq(GITHUB_REPO_FULL_NAME))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> githubProjectCreatorFactory.getDevOpsProjectCreator(mockAlmSettingDto, GITHUB_PROJECT_DESCRIPTOR)) + .isInstanceOf(BadConfigurationException.class) + .hasMessage(format("GitHub auto-provisioning is activated. However the repo %s is not in the scope of the authentication application. " + + "The permissions can't be checked, and the project can not be created.", + GITHUB_REPO_FULL_NAME)); + } + + private void mockValidGitHubSettings() { + when(gitHubSettings.appId()).thenReturn("4324"); + when(gitHubSettings.privateKey()).thenReturn("privateKey"); + when(gitHubSettings.apiURL()).thenReturn(GITHUB_API_URL); + when(gitHubSettings.isProvisioningEnabled()).thenReturn(true); + } + + private void mockSuccessfulGithubInteraction() { + when(githubApplicationClient.getInstallationId(any(), eq(GITHUB_REPO_FULL_NAME))).thenReturn(Optional.of(APP_INSTALLATION_ID)); + when(githubApplicationClient.createAppInstallationToken(any(), eq(APP_INSTALLATION_ID))).thenReturn(Optional.of(appInstallationToken)); + } + + private GithubProjectCreator getExpectedGithubProjectCreator(AlmSettingDto almSettingDto, boolean isInstanceManaged, AccessToken accessToken) { + DevOpsProjectDescriptor devOpsProjectDescriptor = new DevOpsProjectDescriptor(ALM.GITHUB, almSettingDto.getUrl(), GITHUB_REPO_FULL_NAME); + AppInstallationToken authAppInstallToken = isInstanceManaged ? authAppInstallationToken : null; + GithubProjectCreationParameters githubProjectCreationParameters = new GithubProjectCreationParameters(devOpsProjectDescriptor, almSettingDto, userSession, accessToken, + authAppInstallToken); + return new GithubProjectCreator(dbClient, githubApplicationClient, githubPermissionConverter, projectKeyGenerator, permissionUpdater, permissionService, + managedProjectService, projectCreator, githubProjectCreationParameters, gitHubSettings); + } + + private AlmSettingDto mockAlmSettingDto(boolean repoAccess) { + AlmSettingDto almSettingDto = mock(); + when(almSettingDto.getUrl()).thenReturn(repoAccess ? GITHUB_PROJECT_DESCRIPTOR.url() : "anotherUrl"); + when(almSettingDto.getAlm()).thenReturn(ALM.GITHUB); + + when(dbClient.almSettingDao().selectByAlm(dbSession, ALM.GITHUB)).thenReturn(List.of(almSettingDto)); + return almSettingDto; + } + + private void mockAlmPatDto(AlmSettingDto almSettingDto) { + when(userSession.getUuid()).thenReturn("userUuid"); + when(dbClient.almPatDao().selectByUserAndAlmSetting(any(), eq("userUuid"), eq(almSettingDto))) + .thenReturn(Optional.of(new AlmPatDto().setPersonalAccessToken(USER_ACCESS_TOKEN))); + } + +} diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorTest.java new file mode 100644 index 00000000000..dcff559a270 --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/github/GithubProjectCreatorTest.java @@ -0,0 +1,478 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.github; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sonar.alm.client.github.GithubPermissionConverter; +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.web.UserRole; +import org.sonar.auth.github.AppInstallationToken; +import org.sonar.auth.github.GitHubSettings; +import org.sonar.auth.github.GsonRepositoryCollaborator; +import org.sonar.auth.github.GsonRepositoryPermissions; +import org.sonar.auth.github.GsonRepositoryTeam; +import org.sonar.auth.github.client.GithubApplicationClient; +import org.sonar.auth.github.security.AccessToken; +import org.sonar.db.DbClient; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDao; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.component.BranchDto; +import org.sonar.db.project.CreationMethod; +import org.sonar.db.project.ProjectDto; +import org.sonar.db.provisioning.GithubPermissionsMappingDto; +import org.sonar.db.user.GroupDto; +import org.sonar.server.common.almintegration.ProjectKeyGenerator; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.common.component.ComponentCreationParameters; +import org.sonar.server.common.component.ComponentUpdater; +import org.sonar.server.common.component.NewComponent; +import org.sonar.server.common.permission.PermissionUpdater; +import org.sonar.server.common.permission.UserPermissionChange; +import org.sonar.server.common.project.ProjectCreator; +import org.sonar.server.component.ComponentCreationData; +import org.sonar.server.management.ManagedProjectService; +import org.sonar.server.permission.PermissionService; +import org.sonar.server.permission.PermissionServiceImpl; +import org.sonar.server.project.ProjectDefaultVisibility; +import org.sonar.server.project.Visibility; +import org.sonar.server.user.UserSession; + +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.toSet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.sonar.db.project.CreationMethod.ALM_IMPORT_API; +import static org.sonar.db.project.CreationMethod.SCANNER_API_DEVOPS_AUTO_CONFIG; + +@ExtendWith(MockitoExtension.class) +class GithubProjectCreatorTest { + + private static final String ORGANIZATION_NAME = "orga2"; + private static final String REPOSITORY_NAME = "repo1"; + + private static final String MAIN_BRANCH_NAME = "defaultBranch"; + private static final DevOpsProjectDescriptor DEVOPS_PROJECT_DESCRIPTOR = new DevOpsProjectDescriptor(ALM.GITHUB, "http://api.com", ORGANIZATION_NAME + "/" + REPOSITORY_NAME); + private static final String ALM_SETTING_KEY = "github_config_1"; + private static final String USER_LOGIN = "userLogin"; + private static final String USER_UUID = "userUuid"; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private DbClient dbClient; + @Mock + private GithubApplicationClient githubApplicationClient; + @Mock + private GithubPermissionConverter githubPermissionConverter; + @Mock + private ProjectKeyGenerator projectKeyGenerator; + @Mock + private ComponentUpdater componentUpdater; + @Mock + private GithubProjectCreationParameters githubProjectCreationParameters; + @Mock + private AccessToken devOpsAppInstallationToken; + @Mock + private AppInstallationToken authAppInstallationToken; + @Mock + private UserSession userSession; + @Mock + private AlmSettingDto almSettingDto; + private final PermissionService permissionService = new PermissionServiceImpl(mock()); + @Mock + private PermissionUpdater<UserPermissionChange> permissionUpdater; + @Mock + private ManagedProjectService managedProjectService; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ProjectDefaultVisibility projectDefaultVisibility; + private final GitHubSettings gitHubSettings = mock(); + + private GithubProjectCreator githubProjectCreator; + + @Captor + ArgumentCaptor<ComponentCreationParameters> componentCreationParametersCaptor; + @Captor + ArgumentCaptor<ProjectAlmSettingDto> projectAlmSettingDtoCaptor; + + @BeforeEach + void setup() { + lenient().when(userSession.getLogin()).thenReturn(USER_LOGIN); + lenient().when(userSession.getUuid()).thenReturn(USER_UUID); + + lenient().when(almSettingDto.getUrl()).thenReturn(DEVOPS_PROJECT_DESCRIPTOR.url()); + lenient().when(almSettingDto.getKey()).thenReturn(ALM_SETTING_KEY); + + when(githubProjectCreationParameters.devOpsProjectDescriptor()).thenReturn(DEVOPS_PROJECT_DESCRIPTOR); + when(githubProjectCreationParameters.userSession()).thenReturn(userSession); + when(githubProjectCreationParameters.devOpsAppInstallationToken()).thenReturn(devOpsAppInstallationToken); + when(githubProjectCreationParameters.authAppInstallationToken()).thenReturn(authAppInstallationToken); + when(githubProjectCreationParameters.almSettingDto()).thenReturn(almSettingDto); + + ProjectCreator projectCreator = new ProjectCreator(userSession, projectDefaultVisibility, componentUpdater); + githubProjectCreator = new GithubProjectCreator(dbClient, githubApplicationClient, githubPermissionConverter, projectKeyGenerator, + permissionUpdater, permissionService, managedProjectService, projectCreator, githubProjectCreationParameters, gitHubSettings); + + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_whenNoAuthToken_throws() { + when(githubProjectCreationParameters.authAppInstallationToken()).thenReturn(null); + + assertThatIllegalStateException().isThrownBy(() -> githubProjectCreator.isScanAllowedUsingPermissionsFromDevopsPlatform()) + .withMessage("An auth app token is required in case repository permissions checking is necessary."); + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_whenUserIsNotAGitHubUser_returnsFalse() { + assertThat(githubProjectCreator.isScanAllowedUsingPermissionsFromDevopsPlatform()).isFalse(); + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_whenCollaboratorHasDirectAccessButNoScanPermissions_returnsFalse() { + GsonRepositoryCollaborator collaborator1 = mockCollaborator("collaborator1", 1, "role1", "read", "admin"); + mockGithubCollaboratorsFromApi(collaborator1); + bindSessionToCollaborator(collaborator1); + + assertThat(githubProjectCreator.isScanAllowedUsingPermissionsFromDevopsPlatform()).isFalse(); + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_whenCollaboratorHasDirectAccess_returnsTrue() { + GsonRepositoryCollaborator collaborator1 = mockCollaborator("collaborator1", 1, "role1", "read", "admin"); + GsonRepositoryCollaborator collaborator2 = mockCollaborator("collaborator2", 2, "role2", "read", "scan"); + mockGithubCollaboratorsFromApi(collaborator1, collaborator2); + bindSessionToCollaborator(collaborator2); + + assertThat(githubProjectCreator.isScanAllowedUsingPermissionsFromDevopsPlatform()).isTrue(); + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_whenAccessViaTeamButNoScanPermissions_returnsFalse() { + GsonRepositoryTeam team2 = mockGithubTeam("team2", 2, "role2", "another_perm", UserRole.ADMIN); + mockTeamsFromApi(team2); + bindGroupsToUser(team2.name()); + + assertThat(githubProjectCreator.isScanAllowedUsingPermissionsFromDevopsPlatform()).isFalse(); + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_whenAccessViaTeam_returnsTrue() { + GsonRepositoryTeam team1 = mockGithubTeam("team1", 1, "role1", "read", "another_perm"); + GsonRepositoryTeam team2 = mockGithubTeam("team2", 2, "role2", "another_perm", UserRole.SCAN); + mockTeamsFromApi(team1, team2); + bindGroupsToUser(team1.name(), team2.name()); + + assertThat(githubProjectCreator.isScanAllowedUsingPermissionsFromDevopsPlatform()).isTrue(); + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_whenAccessViaTeamButUserNotInTeam_returnsFalse() { + GsonRepositoryTeam team1 = mockGithubTeam("team1", 1, "role1", "read", "another_perm"); + GsonRepositoryTeam team2 = mockGithubTeam("team2", 2, "role2", "another_perm", UserRole.SCAN); + mockTeamsFromApi(team1, team2); + bindGroupsToUser(team1.name()); + + assertThat(githubProjectCreator.isScanAllowedUsingPermissionsFromDevopsPlatform()).isFalse(); + } + + private void bindSessionToCollaborator(GsonRepositoryCollaborator collaborator1) { + UserSession.ExternalIdentity externalIdentity = new UserSession.ExternalIdentity(String.valueOf(collaborator1.id()), collaborator1.name()); + when(userSession.getExternalIdentity()).thenReturn(Optional.of(externalIdentity)); + } + + private GsonRepositoryCollaborator mockCollaborator(String collaboratorLogin, int id, String role1, String... sqPermissions) { + GsonRepositoryCollaborator collaborator = new GsonRepositoryCollaborator(collaboratorLogin, id, role1, + new GsonRepositoryPermissions(false, false, false, false, false)); + mockPermissionsConversion(collaborator, sqPermissions); + return collaborator; + } + + private void mockGithubCollaboratorsFromApi(GsonRepositoryCollaborator... repositoryCollaborators) { + Set<GsonRepositoryCollaborator> collaborators = Arrays.stream(repositoryCollaborators).collect(toSet()); + when(githubApplicationClient.getRepositoryCollaborators(DEVOPS_PROJECT_DESCRIPTOR.url(), authAppInstallationToken, ORGANIZATION_NAME, REPOSITORY_NAME)).thenReturn( + collaborators); + } + + private GsonRepositoryTeam mockGithubTeam(String name, int id, String role, String... sqPermissions) { + GsonRepositoryTeam gsonRepositoryTeam = new GsonRepositoryTeam(name, id, name + "slug", role, new GsonRepositoryPermissions(false, false, false, false, false)); + mockPermissionsConversion(gsonRepositoryTeam, sqPermissions); + return gsonRepositoryTeam; + } + + private void mockTeamsFromApi(GsonRepositoryTeam... repositoryTeams) { + when(githubApplicationClient.getRepositoryTeams(DEVOPS_PROJECT_DESCRIPTOR.url(), authAppInstallationToken, ORGANIZATION_NAME, REPOSITORY_NAME)) + .thenReturn(Arrays.stream(repositoryTeams).collect(toSet())); + } + + private void mockPermissionsConversion(GsonRepositoryCollaborator collaborator, String... sqPermissions) { + Set<GithubPermissionsMappingDto> githubPermissionsMappingDtos = mockPermissionsMappingsDtos(); + lenient().when(githubPermissionConverter.toSonarqubeRolesWithFallbackOnRepositoryPermissions(githubPermissionsMappingDtos, collaborator.roleName(), collaborator.permissions())) + .thenReturn(Arrays.stream(sqPermissions).collect(toSet())); + } + + private void mockPermissionsConversion(GsonRepositoryTeam team, String... sqPermissions) { + Set<GithubPermissionsMappingDto> githubPermissionsMappingDtos = mockPermissionsMappingsDtos(); + lenient().when(githubPermissionConverter.toSonarqubeRolesWithFallbackOnRepositoryPermissions(githubPermissionsMappingDtos, team.permission(), team.permissions())) + .thenReturn(Arrays.stream(sqPermissions).collect(toSet())); + } + + private Set<GithubPermissionsMappingDto> mockPermissionsMappingsDtos() { + Set<GithubPermissionsMappingDto> githubPermissionsMappingDtos = Set.of(mock(GithubPermissionsMappingDto.class)); + when(dbClient.githubPermissionsMappingDao().findAll(any())).thenReturn(githubPermissionsMappingDtos); + return githubPermissionsMappingDtos; + } + + private void bindGroupsToUser(String... groupNames) { + Set<GroupDto> groupDtos = Arrays.stream(groupNames) + .map(groupName -> new GroupDto().setName(ORGANIZATION_NAME + "/" + groupName).setUuid("uuid_" + groupName)) + .collect(toSet()); + when(userSession.getGroups()).thenReturn(groupDtos); + } + + @Test + void createProjectAndBindToDevOpsPlatform_whenRepoNotFound_throws() { + assertThatIllegalStateException().isThrownBy( + () -> githubProjectCreator.createProjectAndBindToDevOpsPlatform(mock(), SCANNER_API_DEVOPS_AUTO_CONFIG, false, null, null)) + .withMessage("Impossible to find the repository 'orga2/repo1' on GitHub, using the devops config " + ALM_SETTING_KEY); + } + + @Test + void createProjectAndBindToDevOpsPlatformFromScanner_whenRepoFoundOnGitHub_successfullyCreatesProject() { + // given + mockGitHubRepository(); + + ComponentCreationData componentCreationData = mockProjectCreation("generated_orga2/repo1"); + ProjectAlmSettingDao projectAlmSettingDao = mock(); + when(dbClient.projectAlmSettingDao()).thenReturn(projectAlmSettingDao); + when(projectDefaultVisibility.get(any())).thenReturn(Visibility.PRIVATE); + + // when + ComponentCreationData actualComponentCreationData = githubProjectCreator.createProjectAndBindToDevOpsPlatform(dbClient.openSession(true), + SCANNER_API_DEVOPS_AUTO_CONFIG, false, null, null); + + // then + assertThat(actualComponentCreationData).isEqualTo(componentCreationData); + + ComponentCreationParameters componentCreationParameters = componentCreationParametersCaptor.getValue(); + assertComponentCreationParametersContainsCorrectInformation(componentCreationParameters, "generated_orga2/repo1", SCANNER_API_DEVOPS_AUTO_CONFIG); + assertThat(componentCreationParameters.isManaged()).isFalse(); + assertThat(componentCreationParameters.newComponent().isPrivate()).isTrue(); + + verify(projectAlmSettingDao).insertOrUpdate(any(), projectAlmSettingDtoCaptor.capture(), eq(ALM_SETTING_KEY), eq(REPOSITORY_NAME), eq("generated_orga2/repo1")); + ProjectAlmSettingDto projectAlmSettingDto = projectAlmSettingDtoCaptor.getValue(); + assertAlmSettingsDtoContainsCorrectInformation(almSettingDto, requireNonNull(componentCreationData.projectDto()), projectAlmSettingDto); + } + + @Test + void createProjectAndBindToDevOpsPlatformFromScanner_whenRepoFoundOnGitHubAndVisibilitySynchronizationEnabled_successfullyCreatesProjectAndSetsVisibility() { + // given + mockPublicGithubRepository(); + + ComponentCreationData componentCreationData = mockProjectCreation("generated_orga2/repo1"); + ProjectAlmSettingDao projectAlmSettingDao = mock(); + when(dbClient.projectAlmSettingDao()).thenReturn(projectAlmSettingDao); + when(gitHubSettings.isProvisioningEnabled()).thenReturn(true); + when(gitHubSettings.isProjectVisibilitySynchronizationActivated()).thenReturn(true); + + // when + ComponentCreationData actualComponentCreationData = githubProjectCreator.createProjectAndBindToDevOpsPlatform(dbClient.openSession(true), + SCANNER_API_DEVOPS_AUTO_CONFIG, false, null, null); + + // then + assertThat(actualComponentCreationData).isEqualTo(componentCreationData); + + ComponentCreationParameters componentCreationParameters = componentCreationParametersCaptor.getValue(); + assertThat(componentCreationParameters.newComponent().isPrivate()).isFalse(); + } + + @Test + void createProjectAndBindToDevOpsPlatformFromScanner_whenRepoFoundOnGitHubAndVisibilitySynchronizationDisabled_successfullyCreatesProjectAndMakesProjectPrivate() { + // given + mockGitHubRepository(); + + ComponentCreationData componentCreationData = mockProjectCreation("generated_orga2/repo1"); + ProjectAlmSettingDao projectAlmSettingDao = mock(); + when(dbClient.projectAlmSettingDao()).thenReturn(projectAlmSettingDao); + when(gitHubSettings.isProvisioningEnabled()).thenReturn(true); + when(gitHubSettings.isProjectVisibilitySynchronizationActivated()).thenReturn(false); + + // when + ComponentCreationData actualComponentCreationData = githubProjectCreator.createProjectAndBindToDevOpsPlatform(dbClient.openSession(true), + SCANNER_API_DEVOPS_AUTO_CONFIG, false, null, null); + + // then + assertThat(actualComponentCreationData).isEqualTo(componentCreationData); + + ComponentCreationParameters componentCreationParameters = componentCreationParametersCaptor.getValue(); + assertThat(componentCreationParameters.newComponent().isPrivate()).isTrue(); + } + + @Test + void createProjectAndBindToDevOpsPlatformFromApi_whenRepoFoundOnGitHub_successfullyCreatesProject() { + // given + String projectKey = "customProjectKey"; + mockGitHubRepository(); + + ComponentCreationData componentCreationData = mockProjectCreation(projectKey); + ProjectAlmSettingDao projectAlmSettingDao = mock(); + when(dbClient.projectAlmSettingDao()).thenReturn(projectAlmSettingDao); + when(projectDefaultVisibility.get(any())).thenReturn(Visibility.PRIVATE); + + // when + ComponentCreationData actualComponentCreationData = githubProjectCreator.createProjectAndBindToDevOpsPlatform(dbClient.openSession(true), ALM_IMPORT_API, false, projectKey, + null); + + // then + assertThat(actualComponentCreationData).isEqualTo(componentCreationData); + + ComponentCreationParameters componentCreationParameters = componentCreationParametersCaptor.getValue(); + assertComponentCreationParametersContainsCorrectInformation(componentCreationParameters, projectKey, ALM_IMPORT_API); + assertThat(componentCreationParameters.isManaged()).isFalse(); + assertThat(componentCreationParameters.newComponent().isPrivate()).isTrue(); + + verify(projectAlmSettingDao).insertOrUpdate(any(), projectAlmSettingDtoCaptor.capture(), eq(ALM_SETTING_KEY), eq(REPOSITORY_NAME), eq(projectKey)); + ProjectAlmSettingDto projectAlmSettingDto = projectAlmSettingDtoCaptor.getValue(); + assertAlmSettingsDtoContainsCorrectInformation(almSettingDto, requireNonNull(componentCreationData.projectDto()), projectAlmSettingDto); + } + + @Captor + private ArgumentCaptor<Collection<UserPermissionChange>> permissionChangesCaptor; + + @Test + void createProjectAndBindToDevOpsPlatformFromApi_whenRepoFoundOnGitHubAutoProvisioningOnAndRepoPrivate_successfullyCreatesProject() { + // given + String projectKey = "customProjectKey"; + mockGitHubRepository(); + + ComponentCreationData componentCreationData = mockProjectCreation(projectKey); + ProjectAlmSettingDao projectAlmSettingDao = mock(); + when(dbClient.projectAlmSettingDao()).thenReturn(projectAlmSettingDao); + when(gitHubSettings.isProvisioningEnabled()).thenReturn(true); + + // when + ComponentCreationData actualComponentCreationData = githubProjectCreator.createProjectAndBindToDevOpsPlatform(dbClient.openSession(true), ALM_IMPORT_API, false, projectKey, + null); + + // then + assertThat(actualComponentCreationData).isEqualTo(componentCreationData); + + ComponentCreationParameters componentCreationParameters = componentCreationParametersCaptor.getValue(); + assertComponentCreationParametersContainsCorrectInformation(componentCreationParameters, projectKey, ALM_IMPORT_API); + assertThat(componentCreationParameters.isManaged()).isTrue(); + assertThat(componentCreationParameters.newComponent().isPrivate()).isTrue(); + + verifyScanPermissionWasAddedToUser(actualComponentCreationData); + verifyProjectSyncTaskWasCreated(actualComponentCreationData); + + verify(projectAlmSettingDao).insertOrUpdate(any(), projectAlmSettingDtoCaptor.capture(), eq(ALM_SETTING_KEY), eq(REPOSITORY_NAME), eq(projectKey)); + ProjectAlmSettingDto projectAlmSettingDto = projectAlmSettingDtoCaptor.getValue(); + assertAlmSettingsDtoContainsCorrectInformation(almSettingDto, requireNonNull(componentCreationData.projectDto()), projectAlmSettingDto); + } + + private void verifyProjectSyncTaskWasCreated(ComponentCreationData componentCreationData) { + String projectUuid = requireNonNull(componentCreationData.projectDto()).getUuid(); + String mainBranchUuid = requireNonNull(componentCreationData.mainBranchDto()).getUuid(); + verify(managedProjectService).queuePermissionSyncTask(USER_UUID, mainBranchUuid, projectUuid); + } + + private void verifyScanPermissionWasAddedToUser(ComponentCreationData actualComponentCreationData) { + verify(permissionUpdater).apply(any(), permissionChangesCaptor.capture()); + UserPermissionChange permissionChange = permissionChangesCaptor.getValue().iterator().next(); + assertThat(permissionChange.getUserId().getUuid()).isEqualTo(userSession.getUuid()); + assertThat(permissionChange.getUserId().getLogin()).isEqualTo(userSession.getLogin()); + assertThat(permissionChange.getPermission()).isEqualTo(UserRole.SCAN); + assertThat(permissionChange.getProjectUuid()).isEqualTo(actualComponentCreationData.projectDto().getUuid()); + } + + private void mockPublicGithubRepository() { + GithubApplicationClient.Repository repository = mockGitHubRepository(); + when(repository.isPrivate()).thenReturn(false); + } + + private GithubApplicationClient.Repository mockGitHubRepository() { + GithubApplicationClient.Repository repository = mock(); + when(repository.getDefaultBranch()).thenReturn(MAIN_BRANCH_NAME); + when(repository.getName()).thenReturn(REPOSITORY_NAME); + when(repository.getFullName()).thenReturn(DEVOPS_PROJECT_DESCRIPTOR.projectIdentifier()); + lenient().when(repository.isPrivate()).thenReturn(true); + when(githubApplicationClient.getRepository(DEVOPS_PROJECT_DESCRIPTOR.url(), devOpsAppInstallationToken, DEVOPS_PROJECT_DESCRIPTOR.projectIdentifier())).thenReturn( + Optional.of(repository)); + when(projectKeyGenerator.generateUniqueProjectKey(repository.getFullName())).thenReturn("generated_" + DEVOPS_PROJECT_DESCRIPTOR.projectIdentifier()); + return repository; + } + + private ComponentCreationData mockProjectCreation(String projectKey) { + ComponentCreationData componentCreationData = mock(); + ProjectDto projectDto = mockProjectDto(projectKey); + when(componentCreationData.projectDto()).thenReturn(projectDto); + BranchDto branchDto = mock(); + when(componentCreationData.mainBranchDto()).thenReturn(branchDto); + when(componentUpdater.createWithoutCommit(any(), componentCreationParametersCaptor.capture())).thenReturn(componentCreationData); + return componentCreationData; + } + + private static ProjectDto mockProjectDto(String projectKey) { + ProjectDto projectDto = mock(); + when(projectDto.getName()).thenReturn(REPOSITORY_NAME); + when(projectDto.getKey()).thenReturn(projectKey); + when(projectDto.getUuid()).thenReturn("project-uuid-1"); + return projectDto; + } + + private static void assertComponentCreationParametersContainsCorrectInformation(ComponentCreationParameters componentCreationParameters, String expectedKey, + CreationMethod expectedCreationMethod) { + assertThat(componentCreationParameters.creationMethod()).isEqualTo(expectedCreationMethod); + assertThat(componentCreationParameters.mainBranchName()).isEqualTo(MAIN_BRANCH_NAME); + assertThat(componentCreationParameters.userLogin()).isEqualTo(USER_LOGIN); + assertThat(componentCreationParameters.userUuid()).isEqualTo(USER_UUID); + + NewComponent newComponent = componentCreationParameters.newComponent(); + assertThat(newComponent.isProject()).isTrue(); + assertThat(newComponent.qualifier()).isEqualTo(Qualifiers.PROJECT); + assertThat(newComponent.key()).isEqualTo(expectedKey); + assertThat(newComponent.name()).isEqualTo(REPOSITORY_NAME); + } + + private static void assertAlmSettingsDtoContainsCorrectInformation(AlmSettingDto almSettingDto, ProjectDto projectDto, ProjectAlmSettingDto projectAlmSettingDto) { + assertThat(projectAlmSettingDto.getAlmRepo()).isEqualTo(DEVOPS_PROJECT_DESCRIPTOR.projectIdentifier()); + assertThat(projectAlmSettingDto.getAlmSlug()).isNull(); + assertThat(projectAlmSettingDto.getAlmSettingUuid()).isEqualTo(almSettingDto.getUuid()); + assertThat(projectAlmSettingDto.getProjectUuid()).isEqualTo(projectDto.getUuid()); + assertThat(projectAlmSettingDto.getMonorepo()).isFalse(); + assertThat(projectAlmSettingDto.getSummaryCommentEnabled()).isTrue(); + } +} diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorFactoryTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorFactoryTest.java new file mode 100644 index 00000000000..2abdd276753 --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorFactoryTest.java @@ -0,0 +1,66 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.gitlab; + +import java.util.Map; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitlabProjectCreatorFactoryTest { + + @InjectMocks + private GitlabProjectCreatorFactory underTest; + + + @Test + void getDevOpsProjectCreator_withCharacteristics_returnsEmpty() { + assertThat(underTest.getDevOpsProjectCreator(mock(DbSession.class), Map.of())).isEmpty(); + } + + + @Test + void getDevOpsProjectCreator_whenDevOpsPlatformIsNotGitlab_returnsEmpty() { + AlmSettingDto almSetting = mock(); + when(almSetting.getAlm()).thenReturn(ALM.AZURE_DEVOPS); + AssertionsForClassTypes.assertThat(underTest.getDevOpsProjectCreator(almSetting, Mockito.mock(DevOpsProjectDescriptor.class))).isEmpty(); + } + + + @Test + void getDevOpsProjectCreator_whenDevOpsPlatformIsNotGitlab_returnsProjectCreator() { + AlmSettingDto almSetting = mock(); + when(almSetting.getAlm()).thenReturn(ALM.GITLAB); + assertThat(underTest.getDevOpsProjectCreator(almSetting, mock(DevOpsProjectDescriptor.class))).isNotEmpty(); + } + +} diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorTest.java new file mode 100644 index 00000000000..76e2d2da308 --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/almsettings/gitlab/GitlabProjectCreatorTest.java @@ -0,0 +1,219 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.almsettings.gitlab; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sonar.alm.client.gitlab.GitLabBranch; +import org.sonar.alm.client.gitlab.GitlabApplicationClient; +import org.sonar.alm.client.gitlab.GitlabServerException; +import org.sonar.alm.client.gitlab.Project; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.pat.AlmPatDto; +import org.sonar.db.alm.setting.ALM; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.alm.setting.ProjectAlmSettingDto; +import org.sonar.db.project.CreationMethod; +import org.sonar.db.project.ProjectDto; +import org.sonar.server.common.almintegration.ProjectKeyGenerator; +import org.sonar.server.common.almsettings.DevOpsProjectDescriptor; +import org.sonar.server.common.project.ProjectCreator; +import org.sonar.server.component.ComponentCreationData; +import org.sonar.server.user.UserSession; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitlabProjectCreatorTest { + + private static final String PROJECT_UUID = "projectUuid"; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private DbClient dbClient; + + @Mock + private ProjectKeyGenerator projectKeyGenerator; + + @Mock + private ProjectCreator projectCreator; + + @Mock + private AlmSettingDto almSettingDto; + @Mock + private DevOpsProjectDescriptor devOpsProjectDescriptor; + @Mock + private GitlabApplicationClient gitlabApplicationClient; + @Mock + private UserSession userSession; + + @InjectMocks + private GitlabProjectCreator underTest; + + private static final String USER_LOGIN = "userLogin"; + private static final String USER_UUID = "userUuid"; + + private static final String GROUP_NAME = "group1"; + private static final String REPOSITORY_PATH_WITH_NAMESPACE = "pathWith/namespace"; + + private static final String GITLAB_PROJECT_NAME = "gitlabProjectName"; + + private static final String REPOSITORY_ID = "1234"; + + private static final String MAIN_BRANCH_NAME = "defaultBranch"; + + private static final String ALM_SETTING_KEY = "gitlab_config_1"; + private static final String ALM_SETTING_UUID = "almSettingUuid"; + + private static final String USER_PAT = "1234"; + + public static final String GITLAB_URL = "http://api.com"; + private static final DevOpsProjectDescriptor DEVOPS_PROJECT_DESCRIPTOR = new DevOpsProjectDescriptor(ALM.GITLAB, GITLAB_URL, REPOSITORY_ID); + + @BeforeEach + void setup() { + lenient().when(userSession.getLogin()).thenReturn(USER_LOGIN); + lenient().when(userSession.getUuid()).thenReturn(USER_UUID); + + lenient().when(almSettingDto.getUrl()).thenReturn(GITLAB_URL); + lenient().when(almSettingDto.getKey()).thenReturn(ALM_SETTING_KEY); + lenient().when(almSettingDto.getUuid()).thenReturn(ALM_SETTING_UUID); + + lenient().when(devOpsProjectDescriptor.projectIdentifier()).thenReturn(REPOSITORY_ID); + lenient().when(devOpsProjectDescriptor.url()).thenReturn(GITLAB_URL); + lenient().when(devOpsProjectDescriptor.alm()).thenReturn(ALM.GITLAB); + } + + @Test + void isScanAllowedUsingPermissionsFromDevopsPlatform_shouldThrowUnsupportedOperationException() { + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> underTest.isScanAllowedUsingPermissionsFromDevopsPlatform()) + .withMessage("Not Implemented"); + } + + @Test + void createProjectAndBindToDevOpsPlatform_whenUserHasNoPat_throws() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> underTest.createProjectAndBindToDevOpsPlatform(mock(DbSession.class), CreationMethod.ALM_IMPORT_API, false, null, null)) + .withMessage("personal access token for 'gitlab_config_1' is missing"); + } + + @Test + void createProjectAndBindToDevOpsPlatform_whenRepoNotFound_throws() { + mockPatForUser(); + when(gitlabApplicationClient.getProject(DEVOPS_PROJECT_DESCRIPTOR.url(), USER_PAT, Long.valueOf(REPOSITORY_ID))).thenThrow(new GitlabServerException(404, "Not found")); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> underTest.createProjectAndBindToDevOpsPlatform(mock(DbSession.class), CreationMethod.ALM_IMPORT_API, false, null, null)) + .withMessage("Failed to fetch GitLab project with ID '1234' from 'http://api.com'"); + + } + + @Test + void createProjectAndBindToDevOpsPlatform_whenRepoFoundOnGitlab_successfullyCreatesProject() { + mockPatForUser(); + mockGitlabProject(); + mockMainBranch(); + mockProjectCreation("projectKey", "projectName"); + + underTest.createProjectAndBindToDevOpsPlatform(mock(DbSession.class), CreationMethod.ALM_IMPORT_API, true, "projectKey", "projectName"); + + ArgumentCaptor<ProjectAlmSettingDto> projectAlmSettingCaptor = ArgumentCaptor.forClass(ProjectAlmSettingDto.class); + + verify(dbClient.projectAlmSettingDao()).insertOrUpdate(any(), projectAlmSettingCaptor.capture(), eq(ALM_SETTING_KEY), eq("projectName"), eq("projectKey")); + + ProjectAlmSettingDto createdProjectAlmSettingDto = projectAlmSettingCaptor.getValue(); + + assertThat(createdProjectAlmSettingDto.getAlmSettingUuid()).isEqualTo(ALM_SETTING_UUID); + assertThat(createdProjectAlmSettingDto.getAlmRepo()).isEqualTo(REPOSITORY_ID); + assertThat(createdProjectAlmSettingDto.getProjectUuid()).isEqualTo(PROJECT_UUID); + assertThat(createdProjectAlmSettingDto.getMonorepo()).isTrue(); + + } + + @Test + void createProjectAndBindToDevOpsPlatform_whenNoKeyAndNameSpecified_generatesOneKeyAndUsersGitlabProjectName() { + mockPatForUser(); + mockGitlabProject(); + mockMainBranch(); + + String generatedProjectKey = "generatedProjectKey"; + when(projectKeyGenerator.generateUniqueProjectKey(REPOSITORY_PATH_WITH_NAMESPACE)).thenReturn(generatedProjectKey); + + mockProjectCreation(generatedProjectKey, GITLAB_PROJECT_NAME); + + underTest.createProjectAndBindToDevOpsPlatform(mock(DbSession.class), CreationMethod.ALM_IMPORT_API, true, null, null); + + ArgumentCaptor<ProjectAlmSettingDto> projectAlmSettingCaptor = ArgumentCaptor.forClass(ProjectAlmSettingDto.class); + + verify(dbClient.projectAlmSettingDao()).insertOrUpdate(any(), projectAlmSettingCaptor.capture(), eq(ALM_SETTING_KEY), eq(GITLAB_PROJECT_NAME), eq(generatedProjectKey)); + + ProjectAlmSettingDto createdProjectAlmSettingDto = projectAlmSettingCaptor.getValue(); + + assertThat(createdProjectAlmSettingDto.getAlmSettingUuid()).isEqualTo(ALM_SETTING_UUID); + assertThat(createdProjectAlmSettingDto.getAlmRepo()).isEqualTo(REPOSITORY_ID); + assertThat(createdProjectAlmSettingDto.getProjectUuid()).isEqualTo(PROJECT_UUID); + assertThat(createdProjectAlmSettingDto.getMonorepo()).isTrue(); + } + + private void mockPatForUser() { + AlmPatDto almPatDto = mock(); + when(almPatDto.getPersonalAccessToken()).thenReturn(USER_PAT); + when(dbClient.almPatDao().selectByUserAndAlmSetting(any(), eq(USER_UUID), eq(almSettingDto))).thenReturn(Optional.of(almPatDto)); + } + + private void mockGitlabProject() { + Project project = mock(Project.class); + lenient().when(project.getPathWithNamespace()).thenReturn(REPOSITORY_PATH_WITH_NAMESPACE); + when(project.getName()).thenReturn(GITLAB_PROJECT_NAME); + when(gitlabApplicationClient.getProject(DEVOPS_PROJECT_DESCRIPTOR.url(), USER_PAT, Long.valueOf(REPOSITORY_ID))).thenReturn(project); + + } + + private void mockMainBranch() { + when(gitlabApplicationClient.getBranches(DEVOPS_PROJECT_DESCRIPTOR.url(), USER_PAT, Long.valueOf(REPOSITORY_ID))) + .thenReturn(List.of(new GitLabBranch("notMain", false), new GitLabBranch(MAIN_BRANCH_NAME, true))); + } + + private void mockProjectCreation(String projectKey, String projectName) { + ComponentCreationData componentCreationData = mock(); + ProjectDto projectDto = mock(); + when(componentCreationData.projectDto()).thenReturn(projectDto); + when(projectDto.getUuid()).thenReturn(PROJECT_UUID); + when(projectDto.getKey()).thenReturn(projectKey); + when(projectDto.getName()).thenReturn(projectName); + when(projectCreator.createProject(any(), eq(projectKey), eq(projectName), eq(MAIN_BRANCH_NAME), eq(CreationMethod.ALM_IMPORT_API))) + .thenReturn(componentCreationData); + } + +} diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/newcodeperiod/CaycUtilsTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/newcodeperiod/CaycUtilsTest.java new file mode 100644 index 00000000000..0afe761eeaa --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/newcodeperiod/CaycUtilsTest.java @@ -0,0 +1,85 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.newcodeperiod; + +import org.junit.Test; +import org.sonar.db.newcodeperiod.NewCodePeriodDto; +import org.sonar.db.newcodeperiod.NewCodePeriodType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class CaycUtilsTest { + + @Test + public void reference_branch_is_compliant() { + var newCodePeriod = new NewCodePeriodDto() + .setType(NewCodePeriodType.REFERENCE_BRANCH) + .setValue("master"); + assertThat(CaycUtils.isNewCodePeriodCompliant(newCodePeriod.getType(), newCodePeriod.getValue())).isTrue(); + } + + @Test + public void previous_version_is_compliant() { + var newCodePeriod = new NewCodePeriodDto() + .setType(NewCodePeriodType.PREVIOUS_VERSION) + .setValue("1.0"); + assertThat(CaycUtils.isNewCodePeriodCompliant(newCodePeriod.getType(), newCodePeriod.getValue())).isTrue(); + } + + @Test + public void number_of_days_smaller_than_90_is_compliant() { + var newCodePeriod = new NewCodePeriodDto() + .setType(NewCodePeriodType.NUMBER_OF_DAYS) + .setValue("30"); + assertThat(CaycUtils.isNewCodePeriodCompliant(newCodePeriod.getType(), newCodePeriod.getValue())).isTrue(); + } + + @Test + public void number_of_days_smaller_than_1_is_not_compliant() { + var newCodePeriod = new NewCodePeriodDto() + .setType(NewCodePeriodType.NUMBER_OF_DAYS) + .setValue("0"); + assertThat(CaycUtils.isNewCodePeriodCompliant(newCodePeriod.getType(), newCodePeriod.getValue())).isFalse(); + } + + @Test + public void number_of_days_bigger_than_90_is_not_compliant() { + var newCodePeriod = new NewCodePeriodDto() + .setType(NewCodePeriodType.NUMBER_OF_DAYS) + .setValue("91"); + assertThat(CaycUtils.isNewCodePeriodCompliant(newCodePeriod.getType(), newCodePeriod.getValue())).isFalse(); + } + + @Test + public void specific_analysis_is_compliant() { + var newCodePeriod = new NewCodePeriodDto() + .setType(NewCodePeriodType.SPECIFIC_ANALYSIS) + .setValue("sdfsafsdf"); + assertThat(CaycUtils.isNewCodePeriodCompliant(newCodePeriod.getType(), newCodePeriod.getValue())).isTrue(); + } + + @Test + public void wrong_number_of_days_format_should_throw_exception() { + assertThatThrownBy(() -> CaycUtils.isNewCodePeriodCompliant(NewCodePeriodType.NUMBER_OF_DAYS, "abc")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse number of days: abc"); + } +}
\ No newline at end of file diff --git a/server/sonar-webserver-common/src/test/java/org/sonar/server/common/newcodeperiod/NewCodeDefinitionResolverTest.java b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/newcodeperiod/NewCodeDefinitionResolverTest.java new file mode 100644 index 00000000000..6bba3336ad9 --- /dev/null +++ b/server/sonar-webserver-common/src/test/java/org/sonar/server/common/newcodeperiod/NewCodeDefinitionResolverTest.java @@ -0,0 +1,147 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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.common.newcodeperiod; + +import java.util.Optional; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.utils.System2; +import org.sonar.core.platform.PlatformEditionProvider; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.DbTester; +import org.sonar.db.newcodeperiod.NewCodePeriodDto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.sonar.db.newcodeperiod.NewCodePeriodType.NUMBER_OF_DAYS; +import static org.sonar.db.newcodeperiod.NewCodePeriodType.PREVIOUS_VERSION; +import static org.sonar.db.newcodeperiod.NewCodePeriodType.REFERENCE_BRANCH; +import static org.sonar.db.newcodeperiod.NewCodePeriodType.SPECIFIC_ANALYSIS; + +public class NewCodeDefinitionResolverTest { + + private static final String MAIN_BRANCH_UUID = "main-branch-uuid"; + @Rule + public DbTester db = DbTester.create(System2.INSTANCE); + + private static final String DEFAULT_PROJECT_ID = "12345"; + + private static final String MAIN_BRANCH = "main"; + + private DbSession dbSession = db.getSession(); + private DbClient dbClient = db.getDbClient(); + private PlatformEditionProvider editionProvider = mock(PlatformEditionProvider.class); + private NewCodeDefinitionResolver newCodeDefinitionResolver = new NewCodeDefinitionResolver(db.getDbClient(), editionProvider); + + @Test + public void createNewCodeDefinition_throw_IAE_if_no_valid_type() { + assertThatThrownBy(() -> newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, "nonValid", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid type: nonValid"); + } + + @Test + public void createNewCodeDefinition_throw_IAE_if_type_is_not_allowed() { + assertThatThrownBy(() -> newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, SPECIFIC_ANALYSIS.name(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid type 'SPECIFIC_ANALYSIS'. `newCodeDefinitionType` can only be set with types: [PREVIOUS_VERSION, NUMBER_OF_DAYS, REFERENCE_BRANCH]"); + } + + @Test + public void createNewCodeDefinition_throw_IAE_if_no_value_for_days() { + assertThatThrownBy(() -> newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, NUMBER_OF_DAYS.name(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("New code definition type 'NUMBER_OF_DAYS' requires a newCodeDefinitionValue"); + } + + @Test + public void createNewCodeDefinition_throw_IAE_if_days_is_invalid() { + assertThatThrownBy(() -> newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, NUMBER_OF_DAYS.name(), "unknown")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse number of days: unknown"); + } + + @Test + public void createNewCodeDefinition_throw_IAE_if_value_is_set_for_reference_branch() { + assertThatThrownBy(() -> newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, REFERENCE_BRANCH.name(), "feature/zw")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unexpected value for newCodeDefinitionType 'REFERENCE_BRANCH'"); + } + + @Test + public void createNewCodeDefinition_throw_IAE_if_previous_version_type_and_value_provided() { + assertThatThrownBy(() -> newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, PREVIOUS_VERSION.name(), "10.2.3")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unexpected value for newCodeDefinitionType 'PREVIOUS_VERSION'"); + } + + @Test + public void createNewCodeDefinition_persist_previous_version_type() { + newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, PREVIOUS_VERSION.name(), null); + + Optional<NewCodePeriodDto> newCodePeriodDto = dbClient.newCodePeriodDao().selectByProject(dbSession, DEFAULT_PROJECT_ID); + assertThat(newCodePeriodDto).map(NewCodePeriodDto::getType).hasValue(PREVIOUS_VERSION); + } + + @Test + public void createNewCodeDefinition_return_days_value_for_number_of_days_type() { + String numberOfDays = "30"; + + newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, NUMBER_OF_DAYS.name(), numberOfDays); + + Optional<NewCodePeriodDto> newCodePeriodDto = dbClient.newCodePeriodDao().selectByProject(dbSession, DEFAULT_PROJECT_ID); + + assertThat(newCodePeriodDto) + .isPresent() + .get() + .extracting(NewCodePeriodDto::getType, NewCodePeriodDto::getValue) + .containsExactly(NUMBER_OF_DAYS, numberOfDays); + } + + @Test + public void createNewCodeDefinition_return_branch_value_for_reference_branch_type() { + newCodeDefinitionResolver.createNewCodeDefinition(dbSession, DEFAULT_PROJECT_ID, MAIN_BRANCH_UUID, MAIN_BRANCH, REFERENCE_BRANCH.name(), null); + + Optional<NewCodePeriodDto> newCodePeriodDto = dbClient.newCodePeriodDao().selectByProject(dbSession, DEFAULT_PROJECT_ID); + + assertThat(newCodePeriodDto) + .isPresent() + .get() + .extracting(NewCodePeriodDto::getType, NewCodePeriodDto::getValue, NewCodePeriodDto::getBranchUuid, NewCodePeriodDto::getProjectUuid) + .containsExactly(REFERENCE_BRANCH, MAIN_BRANCH, null, DEFAULT_PROJECT_ID); + } + + @Test + public void checkNewCodeDefinitionParam_throw_IAE_if_newCodeDefinitionValue_is_provided_without_newCodeDefinitionType() { + assertThatThrownBy(() -> newCodeDefinitionResolver.checkNewCodeDefinitionParam(null, "anyvalue")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("New code definition type is required when new code definition value is provided"); + } + + @Test + public void checkNewCodeDefinitionParam_do_not_throw_when_both_value_and_type_are_provided() { + assertThatNoException() + .isThrownBy(() -> newCodeDefinitionResolver.checkNewCodeDefinitionParam("PREVIOUS_VERSION", "anyvalue")); + } + +} |