CVE-2023-35938 (published 2023-06-29T20:15Z)

 Tuleap is a Free & Open Source Suite to improve management of software developments and collaboration. When switching from a project visibility that allows restricted users to `Private without restricted`, restricted users that are project administrators keep this access right. Restricted users that were project administrators before the visibility switch keep the possibility to access the project and do some administration actions. This issue has been resolved in Tuleap version 14.9.99.63. Users are advised to upgrade. There are no known workarounds for this issue.

CVSS v3 base score: 7.2, HIGH (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-281
cwe_name: Improper Preservation of Permissions
description: The product does not preserve permissions or incorrectly preserves permissions when copying, restoring, or sharing objects, which can cause them to have less restrictive permissions than intended.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/Enalean/tuleap
commit_details: repo_url: https://github.com/Enalean/tuleap
author: Yannis ROSSETTO
author_date: 2023-05-29 11:07:46+02:00
msg: Close request #32278 Removed restricted users are not remove at project visibility switch

When switching from a project visibility that allows restricted users to Private without restricted,
then restricted users that are project administrators keep this access right, meaning that they can
continue to go to project and do some administration actions.

At project visibility switch to Private without restricted, we have to remove the project administration
right to restricted users. If this will remove the last administrator, then the project visibility switch
must be refused.

If the visibility change is triggered and a system events is logged,
then restricted adminsitrators are always removed if restricted users
are no more part of the project.

Note: Nothing is done for Public visibility without restricted as the
current code and implementation does nothing special for this
visibility. This can be handled in a dedicated commit if needed.

Change-Id: I42e2ef7c06e5e5e8af186a19139c532624b6e761
repository: repo_url: https://github.com/Enalean/tuleap
repo_name: Enalean/tuleap
description: Tuleap is an Open Source Suite to improve management of software developments and collaboration. With a single web-based solution, project managers, developers & quality managers can easily build, deploy software projects.
repo_language: PHP
owner: Enalean
file_changes: filename: ldap_sync.php
change_type: ModificationType.MODIFY
diff: @@ -22,6 +22,8 @@
  *
  */
 
+use Tuleap\Project\UserPermissionsDao;
+
 ini_set('max_execution_time', 0);
 ini_set('memory_limit', -1);
 
@@ -71,7 +73,8 @@ if ($ldapPlugin instanceof LdapPlugin) {
                 new \Tuleap\Project\UserRemoverDao(),
                 UserManager::instance(),
                 new ProjectHistoryDao(),
-                new UGroupManager()
+                new UGroupManager(),
+                new UserPermissionsDao(),
             );
 
             $ldapCleanUpManager = new LDAP_CleanUpManager($user_remover, $retentionPeriod);

programming_language: PHP
filename: LDAP_DirectorySynchronization.class.php
change_type: ModificationType.MODIFY
diff: @@ -26,6 +26,7 @@ require_once 'LDAP.class.php';
 require_once 'LDAP_UserManager.class.php';
 require_once 'LDAP_SyncNotificationManager.class.php';
 
+use Tuleap\Project\UserPermissionsDao;
 use Tuleap\Project\UserRemover;
 use Tuleap\Project\UserRemoverDao;
 use Tuleap\User\PasswordVerifier;
@@ -219,7 +220,7 @@ class LDAP_DirectorySynchronization
         return EventManager::instance();
     }
 
-    private function getUserRemover()
+    private function getUserRemover(): UserRemover
     {
         return new UserRemover(
             ProjectManager::instance(),
@@ -228,7 +229,8 @@ class LDAP_DirectorySynchronization
             new UserRemoverDao(),
             UserManager::instance(),
             new ProjectHistoryDao(),
-            new UGroupManager()
+            new UGroupManager(),
+            new UserPermissionsDao(),
         );
     }
 }

programming_language: PHP
filename: ldapPlugin.php
change_type: ModificationType.MODIFY
diff: @@ -46,6 +46,7 @@ use Tuleap\Project\Admin\ProjectUGroup\UGroupEditProcessAction;
 use Tuleap\Project\Admin\ProjectUGroup\UGroupRouter;
 use Tuleap\Project\Registration\RegisterProjectCreationEvent;
 use Tuleap\Project\REST\UserGroupAdditionalInformationEvent;
+use Tuleap\Project\UserPermissionsDao;
 use Tuleap\Project\UserRemover;
 use Tuleap\Project\UserRemoverDao;
 use Tuleap\Request\CollectRoutesEvent;
@@ -1227,7 +1228,7 @@ class LdapPlugin extends Plugin
         );
     }
 
-    private function getUserRemover()
+    private function getUserRemover(): UserRemover
     {
         return new UserRemover(
             ProjectManager::instance(),
@@ -1236,7 +1237,8 @@ class LdapPlugin extends Plugin
             new UserRemoverDao(),
             UserManager::instance(),
             new ProjectHistoryDao(),
-            new UGroupManager()
+            new UGroupManager(),
+            new UserPermissionsDao(),
         );
     }
 

programming_language: PHP
filename: UpdateVisibilityChecker.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,46 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023 - Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\ProjectOwnership\ProjectAdmin;
+
+use Project;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
+use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRetriever;
+
+final class UpdateVisibilityChecker
+{
+    public function __construct(private readonly ProjectOwnerRetriever $project_owner_retriever)
+    {
+    }
+
+    public function canUpdateVisibilityRegardingRestrictedUsers(Project $project): UpdateVisibilityStatus
+    {
+        $project_owner = $this->project_owner_retriever->getProjectOwner($project);
+        if ($project_owner && $project_owner->isRestricted()) {
+            return UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(
+                dgettext('tuleap-project_ownership', 'Cannot switch the project visibility because it will remove the project owner which is restricted.')
+            );
+        }
+
+        return UpdateVisibilityStatus::buildStatusSwitchIsAllowed();
+    }
+}

programming_language: PHP
filename: ProjectOwnerDAO.php
change_type: ModificationType.MODIFY
diff: @@ -42,4 +42,15 @@ class ProjectOwnerDAO extends DataAccessObject
             $project_id
         );
     }
+
+    public function delete(int $project_id, int $user_id): void
+    {
+        $this->getDB()->delete(
+            'plugin_project_ownership_project_owner',
+            [
+                'project_id' => $project_id,
+                'user_id'    => $user_id,
+            ],
+        );
+    }
 }

programming_language: PHP
filename: ProjectOwnerRemover.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,63 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023 - Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\ProjectOwnership\ProjectOwner;
+
+use Project;
+use Psr\Log\LoggerInterface;
+
+class ProjectOwnerRemover
+{
+    public function __construct(
+        private readonly ProjectOwnerDAO $dao,
+        private readonly ProjectOwnerRetriever $project_owner_retriever,
+        private readonly LoggerInterface $logger,
+    ) {
+    }
+
+    public function forceRemovalOfRestrictedProjectOwner(\Project $project, \PFUser $removed_user): void
+    {
+        if ($project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED) {
+            return;
+        }
+
+        if (! $removed_user->isRestricted()) {
+            return;
+        }
+
+        $project_owner = $this->project_owner_retriever->getProjectOwner($project);
+        if ($project_owner === null) {
+            return;
+        }
+
+        if ($project_owner->getId() === $removed_user->getId()) {
+            $this->dao->delete(
+                (int) $project->getID(),
+                (int) $removed_user->getId(),
+            );
+
+            $this->logger->info(
+                "Project owner #" . $removed_user->getId() . " in project # " . $project->getID() . " removed by system event at project visibility switch."
+            );
+        }
+    }
+}

programming_language: PHP
filename: project_ownershipPlugin.php
change_type: ModificationType.MODIFY
diff: @@ -21,16 +21,19 @@
 use Tuleap\admin\ProjectEdit\ProjectStatusUpdate;
 use Tuleap\DB\DBFactory;
 use Tuleap\DB\DBTransactionExecutorWithConnection;
+use Tuleap\Project\Admin\ForceRemovalOfRestrictedAdministrator;
 use Tuleap\Project\Admin\Navigation\NavigationDropdownItemPresenter;
 use Tuleap\Project\Admin\Navigation\NavigationPresenter;
 use Tuleap\Project\Admin\Navigation\NavigationPresenterBuilder;
 use Tuleap\Project\Admin\ProjectUGroup\ApproveProjectAdministratorRemoval;
 use Tuleap\Project\Admin\ProjectUGroup\ProjectImportCleanupUserCreatorFromAdministrators;
 use Tuleap\Project\Admin\ProjectUGroup\ProjectUGroupMemberUpdatable;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityIsAllowedEvent;
 use Tuleap\Project\Registration\RegisterProjectCreationEvent;
 use Tuleap\ProjectOwnership\ProjectAdmin\CannotRemoveProjectOwnerFromTheProjectAdministratorsException;
 use Tuleap\ProjectOwnership\ProjectAdmin\IndexController;
 use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerDAO;
+use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRemover;
 use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRetriever;
 use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerUpdater;
 use Tuleap\ProjectOwnership\ProjectOwner\UserWithStarBadgeFinder;
@@ -212,4 +215,27 @@ class project_ownershipPlugin extends Plugin // phpcs:ignore
             SystemEventManager::instance()
         );
     }
+
+    #[\Tuleap\Plugin\ListeningToEventClass]
+    public function updateVisibilityIsAllowedEvent(UpdateVisibilityIsAllowedEvent $event): void
+    {
+        $update_status = (new Tuleap\ProjectOwnership\ProjectAdmin\UpdateVisibilityChecker(
+            new ProjectOwnerRetriever(new ProjectOwnerDAO(), UserManager::instance()),
+        ))->canUpdateVisibilityRegardingRestrictedUsers($event->getProject());
+
+        $event->setUpdateVisibilityStatus($update_status);
+    }
+
+    #[\Tuleap\Plugin\ListeningToEventClass]
+    public function forceRemovalOfRestrictedAdministrator(ForceRemovalOfRestrictedAdministrator $event): void
+    {
+        (new ProjectOwnerRemover(
+            new ProjectOwnerDAO(),
+            new ProjectOwnerRetriever(new ProjectOwnerDAO(), UserManager::instance()),
+            BackendLogger::getDefaultLogger(),
+        ))->forceRemovalOfRestrictedProjectOwner(
+            $event->project,
+            $event->user,
+        );
+    }
 }

programming_language: PHP
filename: tuleap-project_ownership.po
change_type: ModificationType.MODIFY
diff: @@ -15,6 +15,13 @@ msgstr ""
 msgid "Adds project owner role"
 msgstr "Ajoute le rôle du responsable du projet"
 
+msgid ""
+"Cannot switch the project visibility because it will remove the project "
+"owner which is restricted."
+msgstr ""
+"La visiblité de projet ne peut pas être modifiée car cela va supprimer le "
+"responsable du projet qui est restreint."
+
 msgid "Current project owner:"
 msgstr "Responsable actuel du projet :"
 

programming_language: CoffeeScript
filename: tuleap-project_ownership.po
change_type: ModificationType.MODIFY
diff: @@ -9,6 +9,11 @@ msgstr ""
 msgid "Adds project owner role"
 msgstr ""
 
+msgid ""
+"Cannot switch the project visibility because it will remove the project "
+"owner which is restricted."
+msgstr ""
+
 msgid "Current project owner:"
 msgstr ""
 

programming_language: CoffeeScript
filename: UpdateVisibilityCheckerTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,80 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\ProjectOwnership\ProjectAdmin;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRetriever;
+use Tuleap\Test\Builders\ProjectTestBuilder;
+use Tuleap\Test\Builders\UserTestBuilder;
+use Tuleap\Test\PHPUnit\TestCase;
+
+final class UpdateVisibilityCheckerTest extends TestCase
+{
+    private UpdateVisibilityChecker $checker;
+    private MockObject&ProjectOwnerRetriever $project_owner_retriever;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->project_owner_retriever = $this->createMock(ProjectOwnerRetriever::class);
+        $this->checker                 = new UpdateVisibilityChecker($this->project_owner_retriever);
+    }
+
+    public function testItAllowsProjectVisibilitySwitchIfThereIsNoOwnerInProject(): void
+    {
+        $project = ProjectTestBuilder::aProject()->build();
+
+        $this->project_owner_retriever->method('getProjectOwner')->with($project)->willReturn(null);
+
+        self::assertTrue(
+            $this->checker->canUpdateVisibilityRegardingRestrictedUsers($project)->canSwitch()
+        );
+    }
+
+    public function testItAllowsProjectVisibilitySwitchIfOwnerInProjectIsNotRestricted(): void
+    {
+        $project = ProjectTestBuilder::aProject()->build();
+
+        $this->project_owner_retriever->method('getProjectOwner')->with($project)->willReturn(
+            UserTestBuilder::anActiveUser()->build(),
+        );
+
+        self::assertTrue(
+            $this->checker->canUpdateVisibilityRegardingRestrictedUsers($project)->canSwitch()
+        );
+    }
+
+    public function testItDoesNotAllowProjectVisibilitySwitchIfOwnerInProjectIsRestricted(): void
+    {
+        $project = ProjectTestBuilder::aProject()->build();
+
+        $this->project_owner_retriever->method('getProjectOwner')->with($project)->willReturn(
+            UserTestBuilder::aRestrictedUser()->build(),
+        );
+
+        self::assertFalse(
+            $this->checker->canUpdateVisibilityRegardingRestrictedUsers($project)->canSwitch()
+        );
+    }
+}

programming_language: PHP
filename: ProjectOwnerRemoverTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,129 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023 - Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\ProjectOwnership\ProjectOwner;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Psr\Log\NullLogger;
+use Tuleap\Test\Builders\ProjectTestBuilder;
+use Tuleap\Test\Builders\UserTestBuilder;
+use Tuleap\Test\PHPUnit\TestCase;
+
+final class ProjectOwnerRemoverTest extends TestCase
+{
+    private ProjectOwnerRemover $remover;
+    private MockObject&ProjectOwnerDAO $dao;
+    private MockObject&ProjectOwnerRetriever $project_owner_retriever;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->dao                     = $this->createMock(ProjectOwnerDAO::class);
+        $this->project_owner_retriever = $this->createMock(ProjectOwnerRetriever::class);
+
+        $this->remover = new ProjectOwnerRemover(
+            $this->dao,
+            $this->project_owner_retriever,
+            new NullLogger(),
+        );
+    }
+
+    public function testItDoesNothingIfProjectIsNotPrivateWithoutRestricted(): void
+    {
+        $public_project                 = ProjectTestBuilder::aProject()->withAccessPublic()->build();
+        $private_project                = ProjectTestBuilder::aProject()->withAccessPrivate()->build();
+        $public_project_with_restricted = ProjectTestBuilder::aProject()->withAccessPublicIncludingRestricted()->build();
+
+        $this->dao->expects(self::never())->method('delete');
+
+        $this->remover->forceRemovalOfRestrictedProjectOwner(
+            $public_project,
+            UserTestBuilder::aRestrictedUser()->build(),
+        );
+
+        $this->remover->forceRemovalOfRestrictedProjectOwner(
+            $private_project,
+            UserTestBuilder::aRestrictedUser()->build(),
+        );
+
+        $this->remover->forceRemovalOfRestrictedProjectOwner(
+            $public_project_with_restricted,
+            UserTestBuilder::aRestrictedUser()->build(),
+        );
+    }
+
+    public function testItDoesNothingIfUserIsNotRestricted(): void
+    {
+        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();
+
+        $this->dao->expects(self::never())->method('delete');
+
+        $this->remover->forceRemovalOfRestrictedProjectOwner(
+            $project,
+            UserTestBuilder::anActiveUser()->build(),
+        );
+    }
+
+    public function testItDoesNothingIfThereIsNoOwnerInProject(): void
+    {
+        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();
+
+        $this->project_owner_retriever->method('getProjectOwner')->willReturn(null);
+        $this->dao->expects(self::never())->method('delete');
+
+        $this->remover->forceRemovalOfRestrictedProjectOwner(
+            $project,
+            UserTestBuilder::aRestrictedUser()->build(),
+        );
+    }
+
+    public function testItDoesNothingIfTheRemovedUserIsNotTheOwnerInProject(): void
+    {
+        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();
+
+        $this->project_owner_retriever->method('getProjectOwner')->willReturn(
+            UserTestBuilder::anActiveUser()->withId(103)->build()
+        );
+        $this->dao->expects(self::never())->method('delete');
+
+        $this->remover->forceRemovalOfRestrictedProjectOwner(
+            $project,
+            UserTestBuilder::aRestrictedUser()->withId(102)->build(),
+        );
+    }
+
+    public function testItForcesTheRemovalOfTheOwnerInProject(): void
+    {
+        $project = ProjectTestBuilder::aProject()->withAccessPrivateWithoutRestricted()->build();
+
+        $this->project_owner_retriever->method('getProjectOwner')->willReturn(
+            UserTestBuilder::anActiveUser()->withId(102)->build()
+        );
+        $this->dao->expects(self::once())->method('delete');
+
+        $this->remover->forceRemovalOfRestrictedProjectOwner(
+            $project,
+            UserTestBuilder::aRestrictedUser()->withId(102)->build(),
+        );
+    }
+}

programming_language: PHP
filename: tuleap-core.po
change_type: ModificationType.MODIFY
diff: @@ -900,6 +900,14 @@ msgstr "Impossible d'enlever l'utilisateur du groupe"
 msgid "Cannot remove users from bound groups"
 msgstr "Impossible d'enlever des utilisateurs d'un group lié"
 
+msgid ""
+"Cannot switch the project visibility because it will remove every restricted "
+"users from the project, and after that no administrator will be left."
+msgstr ""
+"La visibilité ne peut être changée car elle va supprimer tous les "
+"utilisateurs restreints du projet, et par conséquent aucun administrateur de "
+"projet ne sera présent."
+
 msgid "Cannot update the requested dashboard."
 msgstr "Impossible d'éditer le tableau de bord demandé."
 
@@ -2787,7 +2795,7 @@ msgid "Private archives"
 msgstr "Archives privées"
 
 msgid "Private incl. restricted"
-msgstr "Privé incl. les utilisateurs restreints"
+msgstr "Privé, incluant les utilisateurs restreints"
 
 msgid "Private including restricted"
 msgstr "Privé incluant les utilisateurs restreints"

programming_language: CoffeeScript
filename: tuleap-core.po
change_type: ModificationType.MODIFY
diff: @@ -886,6 +886,11 @@ msgstr "Não é possível remover o usuário do grupo"
 msgid "Cannot remove users from bound groups"
 msgstr "Não é possível remover usuários de grupos vinculados"
 
+msgid ""
+"Cannot switch the project visibility because it will remove every restricted "
+"users from the project, and after that no administrator will be left."
+msgstr ""
+
 msgid "Cannot update the requested dashboard."
 msgstr "Não é possível atualizar o painel solicitado."
 

programming_language: CoffeeScript
filename: ForgeConfig.php
change_type: ModificationType.MODIFY
diff: @@ -300,7 +300,7 @@ class ForgeConfig
         return self::get(ForgeAccess::CONFIG) === ForgeAccess::ANONYMOUS;
     }
 
-    public static function areRestrictedUsersAllowed()
+    public static function areRestrictedUsersAllowed(): bool
     {
         return self::get(ForgeAccess::CONFIG) === ForgeAccess::RESTRICTED;
     }

programming_language: PHP
filename: ForceRemovalOfRestrictedAdministrator.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,37 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2018 - Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Tuleap\Project\Admin;
+
+use PFUser;
+use Project;
+use Tuleap\Event\Dispatchable;
+
+/**
+ * @psalm-immutable
+ */
+final class ForceRemovalOfRestrictedAdministrator implements Dispatchable
+{
+    public function __construct(
+        public readonly Project $project,
+        public readonly PFUser $user,
+    ) {
+    }
+}

programming_language: PHP
filename: ProjectDetailsController.php
change_type: ModificationType.MODIFY
diff: @@ -41,6 +41,7 @@ use Tuleap\Project\Admin\DescriptionFields\DescriptionFieldLabelBuilder;
 use Tuleap\Project\Admin\Navigation\HeaderNavigationDisplayer;
 use Tuleap\Project\Admin\ProjectVisibilityPresenterBuilder;
 use Tuleap\Project\Admin\ProjectVisibilityUserConfigurationPermissions;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
 use Tuleap\Project\DescriptionFieldsFactory;
 use Tuleap\Project\HierarchyDisplayer;
 use Tuleap\Project\Icons\EmojiCodepointConverter;
@@ -126,6 +127,7 @@ class ProjectDetailsController
         CSRFSynchronizerToken $csrf_token,
         TemplateFactory $template_factory,
         private ProjectIconRetriever $project_icon_retriever,
+        private readonly UpdateVisibilityChecker $update_visibility_checker,
     ) {
         $this->description_fields_factory           = $description_fields_factory;
         $this->current_project                      = $current_project;
@@ -507,9 +509,21 @@ class ProjectDetailsController
         if ($this->project_visibility_configuration->canUserConfigureProjectVisibility($user, $project)) {
             if ($project->getAccess() !== $request->get('project_visibility')) {
                 if ($request->get('term_of_service')) {
-                    $this->project_manager->setAccess($project, $request->get('project_visibility'));
-                    $this->project_manager->clear($project->getID());
-                    $this->ugroup_binding->reloadUgroupBindingInProject($project);
+                    $update_visibility_status = $this->update_visibility_checker->canUpdateVisibilityRegardingRestrictedUsers(
+                        $project,
+                        $request->get('project_visibility'),
+                    );
+
+                    if ($update_visibility_status->canSwitch()) {
+                        $this->project_manager->setAccess($project, $request->get('project_visibility'));
+                        $this->project_manager->clear($project->getID());
+                        $this->ugroup_binding->reloadUgroupBindingInProject($project);
+                    } else {
+                        $GLOBALS['Response']->addFeedback(
+                            Feedback::ERROR,
+                            $update_visibility_status->getReason(),
+                        );
+                    }
                 } else {
                     $GLOBALS['Response']->addFeedback(Feedback::ERROR, _("Please accept term of service"));
                 }

programming_language: PHP
filename: ProjectMembersController.php
change_type: ModificationType.MODIFY
diff: @@ -169,7 +169,8 @@ class ProjectMembersController implements DispatchableWithRequest, DispatchableW
                 new UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                $ugroup_manager
+                $ugroup_manager,
+                new UserPermissionsDao(),
             ),
             $event_manager,
             $ugroup_manager,

programming_language: PHP
filename: MemberRemovalController.php
change_type: ModificationType.MODIFY
diff: @@ -126,7 +126,8 @@ class MemberRemovalController implements DispatchableWithRequest
                 new UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                $ugroup_manager
+                $ugroup_manager,
+                new UserPermissionsDao(),
             ),
             UGroupRouter::getCSRFTokenSynchronizer()
         );

programming_language: PHP
filename: ProjectVisibilityOptionsForPresenterGenerator.php
change_type: ModificationType.MODIFY
diff: @@ -23,14 +23,16 @@ declare(strict_types=1);
 namespace Tuleap\Project\Admin;
 
 use Project;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
 
 final class ProjectVisibilityOptionsForPresenterGenerator
 {
     /**
-     * @psalm-return array<array{value: string, label: string, selected: string}>
+     * @psalm-return array<array{value: string, label: string, selected: string, disabled: bool, title: string}>
      */
     public function generateVisibilityOptions(
         bool $does_platform_allow_restricted_users,
+        UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
         string $current_project_visibility,
     ): array {
         if ($does_platform_allow_restricted_users) {
@@ -39,21 +41,29 @@ final class ProjectVisibilityOptionsForPresenterGenerator
                     'value'    => Project::ACCESS_PRIVATE_WO_RESTRICTED,
                     'label'    => _('Private'),
                     'selected' => ($current_project_visibility === Project::ACCESS_PRIVATE_WO_RESTRICTED) ? 'selected = "selected"' : '',
+                    'disabled' => ! $private_without_restricted_visibility_switch_status->canSwitch(),
+                    'title'    => ! $private_without_restricted_visibility_switch_status->canSwitch() ? $private_without_restricted_visibility_switch_status->getReason() : '',
                 ],
                 [
                     'value'    => Project::ACCESS_PRIVATE,
                     'label'    => _('Private incl. restricted'),
                     'selected' => ($current_project_visibility === Project::ACCESS_PRIVATE) ? 'selected = "selected"' : '',
+                    'disabled' => false,
+                    'title'    => '',
                 ],
                 [
                     'value'    => Project::ACCESS_PUBLIC,
                     'label'    => _('Public'),
                     'selected' => ($current_project_visibility === Project::ACCESS_PUBLIC) ? 'selected = "selected"' : '',
+                    'disabled' => false,
+                    'title'    => '',
                 ],
                 [
                     'value'    => Project::ACCESS_PUBLIC_UNRESTRICTED,
                     'label'    => _('Public incl. restricted'),
                     'selected' => ($current_project_visibility === Project::ACCESS_PUBLIC_UNRESTRICTED) ? 'selected = "selected"' : '',
+                    'disabled' => false,
+                    'title'    => '',
                 ],
             ];
         }
@@ -62,11 +72,15 @@ final class ProjectVisibilityOptionsForPresenterGenerator
                 'value'    => Project::ACCESS_PRIVATE,
                 'label'    => _('Private'),
                 'selected' => ($current_project_visibility === Project::ACCESS_PRIVATE) ? 'selected = "selected"' : '',
+                'disabled' => false,
+                'title'    => '',
             ],
             [
                 'value'    => Project::ACCESS_PUBLIC,
                 'label'    => _('Public'),
                 'selected' => ($current_project_visibility === Project::ACCESS_PUBLIC) ? 'selected = "selected"' : '',
+                'disabled' => false,
+                'title'    => '',
             ],
         ];
     }

programming_language: PHP
filename: ProjectVisibilityPresenter.class.php
change_type: ModificationType.MODIFY
diff: @@ -21,6 +21,7 @@ namespace Tuleap\Project\Admin;
 
 use BaseLanguage;
 use Codendi_HTMLPurifier;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
 
 class ProjectVisibilityPresenter
 {
@@ -54,12 +55,13 @@ class ProjectVisibilityPresenter
 
     public function __construct(
         BaseLanguage $language,
-        $platform_allows_restricted,
-        $project_visibility,
+        bool $platform_allows_restricted,
+        UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
+        string $project_visibility,
         int $number_of_restricted_users_in_project,
         ProjectVisibilityOptionsForPresenterGenerator $project_visibility_options_generator,
     ) {
-        $this->platform_allows_restricted       = (bool) $platform_allows_restricted;
+        $this->platform_allows_restricted       = $platform_allows_restricted;
         $this->restricted_warning_message       = $language->getText(
             'project_admin_editgroupinfo',
             'restricted_warning'
@@ -76,10 +78,12 @@ class ProjectVisibilityPresenter
         $this->project_visibility_label = _('Project visibility');
         $this->accept_tos_message       = _("Please accept term of service");
 
-        $this->options                               = $project_visibility_options_generator->generateVisibilityOptions(
+        $this->options = $project_visibility_options_generator->generateVisibilityOptions(
             $this->platform_allows_restricted,
+            $private_without_restricted_visibility_switch_status,
             $project_visibility
         );
+
         $this->number_of_restricted_users_in_project = $number_of_restricted_users_in_project;
     }
 }

programming_language: PHP
filename: ProjectVisibilityPresenterBuilder.php
change_type: ModificationType.MODIFY
diff: @@ -26,7 +26,9 @@ namespace Tuleap\Project\Admin;
 
 use ForgeConfig;
 use HTTPRequest;
+use Project;
 use ProjectTruncatedEmailsPresenter;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
 use Tuleap\Project\ProjectAccessPresenter;
 
 class ProjectVisibilityPresenterBuilder
@@ -54,6 +56,7 @@ class ProjectVisibilityPresenterBuilder
         ServicesUsingTruncatedMailRetriever $service_truncated_mails_retriever,
         RestrictedUsersProjectCounter $restricted_users_project_counter,
         ProjectVisibilityOptionsForPresenterGenerator $project_visibility_options_generator,
+        private readonly UpdateVisibilityChecker $update_visibility_checker,
     ) {
         $this->project_visibility_configuration     = $project_visibility_configuration;
         $this->service_truncated_mails_retriever    = $service_truncated_mails_retriever;
@@ -68,6 +71,10 @@ class ProjectVisibilityPresenterBuilder
         $visibility_presenter = new ProjectVisibilityPresenter(
             $GLOBALS['Language'],
             ForgeConfig::areRestrictedUsersAllowed(),
+            $this->update_visibility_checker->canUpdateVisibilityRegardingRestrictedUsers(
+                $project,
+                Project::ACCESS_PRIVATE_WO_RESTRICTED,
+            ),
             $project->getAccess(),
             $this->restricted_users_project_counter->getNumberOfRestrictedUsersInProject($project),
             $this->project_visibility_options_generator

programming_language: PHP
filename: UpdateVisibilityChecker.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,68 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
+ *
+ *  This file is a part of Tuleap.
+ *
+ *  Tuleap is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation; either version 2 of the License, or
+ *  (at your option) any later version.
+ *
+ *  Tuleap 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 General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\Project\Admin\Visibility;
+
+use ForgeConfig;
+use Project;
+use Psr\EventDispatcher\EventDispatcherInterface;
+
+final class UpdateVisibilityChecker
+{
+    public function __construct(
+        private readonly EventDispatcherInterface $event_dispatcher,
+    ) {
+    }
+
+    public function canUpdateVisibilityRegardingRestrictedUsers(Project $project, string $visibility): UpdateVisibilityStatus
+    {
+        if (
+            ! ForgeConfig::areRestrictedUsersAllowed() ||
+            $visibility !== Project::ACCESS_PRIVATE_WO_RESTRICTED
+        ) {
+            return UpdateVisibilityStatus::buildStatusSwitchIsAllowed();
+        }
+
+        if (! $this->atLeastOneAdministratorIsActive($project)) {
+            return UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(
+                _("Cannot switch the project visibility because it will remove every restricted users from the project, and after that no administrator will be left."),
+            );
+        }
+
+        $event = $this->event_dispatcher->dispatch(
+            new UpdateVisibilityIsAllowedEvent($project)
+        );
+
+        return $event->getUpdateVisibilityStatus();
+    }
+
+    private function atLeastOneAdministratorIsActive(Project $project): bool
+    {
+        foreach ($project->getAdmins() as $project_admin) {
+            if ($project_admin->isActive()) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}

programming_language: PHP
filename: UpdateVisibilityIsAllowedEvent.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,51 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
+ *
+ *  This file is a part of Tuleap.
+ *
+ *  Tuleap is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation; either version 2 of the License, or
+ *  (at your option) any later version.
+ *
+ *  Tuleap 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 General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\Project\Admin\Visibility;
+
+use Project;
+use Tuleap\Event\Dispatchable;
+
+final class UpdateVisibilityIsAllowedEvent implements Dispatchable
+{
+    private UpdateVisibilityStatus $update_visibility_status;
+
+    public function __construct(private readonly Project $project)
+    {
+        $this->update_visibility_status = UpdateVisibilityStatus::buildStatusSwitchIsAllowed();
+    }
+
+    public function getUpdateVisibilityStatus(): UpdateVisibilityStatus
+    {
+        return $this->update_visibility_status;
+    }
+
+    public function setUpdateVisibilityStatus(UpdateVisibilityStatus $update_visibility_status): void
+    {
+        $this->update_visibility_status = $update_visibility_status;
+    }
+
+    public function getProject(): Project
+    {
+        return $this->project;
+    }
+}

programming_language: PHP
filename: UpdateVisibilityStatus.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,52 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
+ *
+ *  This file is a part of Tuleap.
+ *
+ *  Tuleap is free software; you can redistribute it and/or modify
+ *  it under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation; either version 2 of the License, or
+ *  (at your option) any later version.
+ *
+ *  Tuleap 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 General Public License for more details.
+ *
+ *  You should have received a copy of the GNU General Public License
+ *  along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\Project\Admin\Visibility;
+
+final class UpdateVisibilityStatus
+{
+    private function __construct(
+        private readonly bool $can_switch,
+        private readonly string $reason,
+    ) {
+    }
+
+    public static function buildStatusSwitchIsAllowed(): self
+    {
+        return new self(true, '');
+    }
+
+    public static function buildStatusSwitchIsNotAllowed(string $reason): self
+    {
+        return new self(false, $reason);
+    }
+
+    public function canSwitch(): bool
+    {
+        return $this->can_switch;
+    }
+
+    public function getReason(): string
+    {
+        return $this->reason;
+    }
+}

programming_language: PHP
filename: Project.class.php
change_type: ModificationType.MODIFY
diff: @@ -517,9 +517,9 @@ class Project extends Group implements PFO_Project  // phpcs:ignore PSR1.Classes
     }
 
     /**
-     * @return array of User admin of the project
+     * @return PFUser[] of User admin of the project
      */
-    public function getAdmins(?UGroupManager $ugm = null)
+    public function getAdmins(?UGroupManager $ugm = null): array
     {
         if (is_null($ugm)) {
             $ugm = $this->getUGroupManager();

programming_language: PHP
filename: ProjectXMLImporter.class.php
change_type: ModificationType.MODIFY
diff: @@ -44,6 +44,7 @@ use Tuleap\Project\SystemEventRunnerInterface;
 use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectMemberAdder;
 use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectMemberAdderWithoutStatusCheckAndNotifications;
 use Tuleap\Project\UGroups\SynchronizedProjectMembershipDao;
+use Tuleap\Project\UserPermissionsDao;
 use Tuleap\Project\UserRemover;
 use Tuleap\Project\UserRemoverDao;
 use Tuleap\Project\XML\Import\ArchiveInterface;
@@ -193,7 +194,8 @@ class ProjectXMLImporter //phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNam
                 new UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                new UGroupManager()
+                new UGroupManager(),
+                new UserPermissionsDao(),
             ),
             ProjectMemberAdderWithoutStatusCheckAndNotifications::build(),
             $project_creator,

programming_language: PHP
filename: ProjectRegistrationPresenter.php
change_type: ModificationType.MODIFY
diff: @@ -94,7 +94,7 @@ class ProjectRegistrationPresenter
         array $external_templates,
     ) {
         $this->tuleap_templates             = json_encode($tuleap_templates);
-        $this->are_restricted_users_allowed = (bool) ForgeConfig::areRestrictedUsersAllowed();
+        $this->are_restricted_users_allowed = ForgeConfig::areRestrictedUsersAllowed();
         $this->project_default_visibility   = $project_default_visibility;
         $this->projects_must_be_approved    = (bool) ForgeConfig::get(
             ProjectManager::CONFIG_PROJECT_APPROVAL,

programming_language: PHP
filename: UGroupManager.class.php
change_type: ModificationType.MODIFY
diff: @@ -604,10 +604,7 @@ class UGroupManager implements \Tuleap\Project\UGroupRetriever // phpcs:ignore P
         }
     }
 
-    /**
-     * @return UserRemover
-     */
-    private function getUserRemover()
+    private function getUserRemover(): UserRemover
     {
         return new UserRemover(
             ProjectManager::instance(),
@@ -616,7 +613,8 @@ class UGroupManager implements \Tuleap\Project\UGroupRetriever // phpcs:ignore P
             new UserRemoverDao(),
             UserManager::instance(),
             new ProjectHistoryDao(),
-            new UGroupManager()
+            new UGroupManager(),
+            new UserPermissionsDao(),
         );
     }
 }

programming_language: PHP
filename: UserRemover.php
change_type: ModificationType.MODIFY
diff: @@ -24,6 +24,8 @@ use ProjectManager;
 use EventManager;
 use ArtifactTypeFactory;
 use Feedback;
+use Tuleap\Project\Admin\ForceRemovalOfRestrictedAdministrator;
+use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectAdminHistoryEntry;
 use UserManager;
 use ProjectHistoryDao;
 use Project;
@@ -74,6 +76,7 @@ class UserRemover
         UserManager $user_manager,
         ProjectHistoryDao $project_history_dao,
         UGroupManager $ugroup_manager,
+        private readonly UserPermissionsDao $user_permissions_dao,
     ) {
         $this->project_manager     = $project_manager;
         $this->event_manager       = $event_manager;
@@ -84,11 +87,36 @@ class UserRemover
         $this->ugroup_manager      = $ugroup_manager;
     }
 
+    public function forceRemoveAdminRestrictedUserFromProject(Project $project, \PFUser $removed_user): void
+    {
+        if (! $removed_user->isRestricted()) {
+            return;
+        }
+
+        $this->user_permissions_dao->removeUserFromProjectAdmin($project->getID(), $removed_user->getId());
+        $this->project_history_dao->addHistory(
+            $project,
+            $this->user_manager->getUserAnonymous(),
+            new \DateTimeImmutable(),
+            ProjectAdminHistoryEntry::Remove->value,
+            $removed_user->getUserName() . " (" . $removed_user->getId() . ")",
+        );
+
+        $this->event_manager->dispatch(
+            new ForceRemovalOfRestrictedAdministrator($project, $removed_user),
+        );
+
+        $this->removeUserFromProject(
+            $project->getID(),
+            $removed_user->getID(),
+        );
+    }
+
     public function removeUserFromProject($project_id, $user_id, $admin_action = true)
     {
         $project = $this->getProject($project_id);
 
-        if (! $this->dao->removeUserFromProject($project_id, $user_id)) {
+        if (! $this->dao->removeNonAdminUserFromProject($project_id, $user_id)) {
             $GLOBALS['Response']->addFeedback(
                 Feedback::ERROR,
                 $GLOBALS['Language']->getText('project_admin_index', 'user_not_removed')

programming_language: PHP
filename: UserRemoverDao.php
change_type: ModificationType.MODIFY
diff: @@ -24,7 +24,7 @@ use DataAccessObject;
 
 class UserRemoverDao extends DataAccessObject
 {
-    public function removeUserFromProject($project_id, $user_id)
+    public function removeNonAdminUserFromProject($project_id, $user_id): bool
     {
         $project_id = $this->da->escapeInt($project_id);
         $user_id    = $this->da->escapeInt($user_id);

programming_language: PHP
filename: RouteCollector.php
change_type: ModificationType.MODIFY
diff: @@ -178,6 +178,7 @@ use Tuleap\Project\Service\DeleteController;
 use Tuleap\Project\Service\EditController;
 use Tuleap\Project\Service\IndexController;
 use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectMemberAdderWithStatusCheckAndNotifications;
+use Tuleap\Project\UserPermissionsDao;
 use Tuleap\REST\BasicAuthentication;
 use Tuleap\REST\RESTCurrentUserMiddleware;
 use Tuleap\REST\TuleapRESTCORSMiddleware;
@@ -583,7 +584,8 @@ class RouteCollector
                 new \Tuleap\Project\UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                new UGroupManager()
+                new UGroupManager(),
+                new UserPermissionsDao(),
             ),
             new ProjectAdministratorsIncludingDelegationDAO(),
             new SapiEmitter()

programming_language: PHP
filename: SwitchToPresenterBuilder.php
change_type: ModificationType.MODIFY
diff: @@ -43,7 +43,7 @@ final class SwitchToPresenterBuilder
 
         return new SwitchToPresenter(
             $this->project_presenters_builder->getProjectPresenters($user),
-            (bool) \ForgeConfig::areRestrictedUsersAllowed(),
+            \ForgeConfig::areRestrictedUsersAllowed(),
             (bool) \ForgeConfig::get('sys_use_trove'),
             $user->isAlive() && ! $user->isRestricted(),
             $this->search_form_presenter_builder->build(),

programming_language: PHP
filename: ProjectVisibilityConfigPresenter.php
change_type: ModificationType.MODIFY
diff: @@ -23,6 +23,7 @@ namespace Tuleap\admin\ProjectCreation\ProjectVisibility;
 use ForgeConfig;
 use Tuleap\Admin\ProjectCreationNavBarPresenter;
 use Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
 
 class ProjectVisibilityConfigPresenter
 {
@@ -63,6 +64,7 @@ class ProjectVisibilityConfigPresenter
         $this->send_mail_on_project_visibility_change = (bool) ForgeConfig::get(ProjectVisibilityConfigManager::SEND_MAIL_ON_PROJECT_VISIBILITY_CHANGE);
         $this->default_project_visibility_options     = $project_visibility_options_generator->generateVisibilityOptions(
             ForgeConfig::areRestrictedUsersAllowed(),
+            UpdateVisibilityStatus::buildStatusSwitchIsAllowed(),
             $current_default_project_visibility_retriever
         );
     }

programming_language: PHP
filename: SystemEventManager.class.php
change_type: ModificationType.MODIFY
diff: @@ -20,6 +20,7 @@
  */
 
 use Tuleap\admin\ProjectEdit\ProjectStatusUpdate;
+use Tuleap\Project\UserPermissionsDao;
 use Tuleap\Project\UserRemover;
 use Tuleap\Project\UserRemoverDao;
 use Tuleap\SVNCore\Event\UpdateProjectAccessFilesScheduler;
@@ -488,7 +489,8 @@ class SystemEventManager
                         new UserRemoverDao(),
                         $user_manager,
                         new ProjectHistoryDao(),
-                        new UGroupManager()
+                        new UGroupManager(),
+                        new UserPermissionsDao(),
                     ),
                 ];
                 break;
@@ -532,7 +534,8 @@ class SystemEventManager
                         new UserRemoverDao(),
                         UserManager::instance(),
                         new ProjectHistoryDao(),
-                        $ugroup_manager
+                        $ugroup_manager,
+                        new UserPermissionsDao(),
                     ),
                     $ugroup_manager,
                 ];

programming_language: PHP
filename: SystemEvent_PROJECT_IS_PRIVATE.class.php
change_type: ModificationType.MODIFY
diff: @@ -26,18 +26,11 @@ use Tuleap\Project\UserRemover;
 
 /**
 * System Event classes
-*
 */
 class SystemEvent_PROJECT_IS_PRIVATE extends SystemEvent
 {
-    /**
-     * @var UserRemover
-     */
-    private $user_remover;
-    /**
-     * @var UGroupManager
-     */
-    private $ugroup_manager;
+    private UserRemover $user_remover;
+    private UGroupManager $ugroup_manager;
 
     public function injectDependencies(
         UserRemover $user_remover,
@@ -115,9 +108,23 @@ class SystemEvent_PROJECT_IS_PRIVATE extends SystemEvent
 
     private function cleanRestrictedUsersIfNecessary(Project $project): void
     {
-        if (! ForgeConfig::areRestrictedUsersAllowed() || $project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED) {
+        if (
+            ! ForgeConfig::areRestrictedUsersAllowed() ||
+            $project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED
+        ) {
             return;
         }
+
+        $project_admins = $project->getAdmins();
+        foreach ($project_admins as $project_admin) {
+            if ($project_admin->isRestricted()) {
+                $this->user_remover->forceRemoveAdminRestrictedUserFromProject(
+                    $project,
+                    $project_admin,
+                );
+            }
+        }
+
         $project_members = $project->getMembers();
         foreach ($project_members as $project_member) {
             if ($project_member->isRestricted()) {

programming_language: PHP
filename: project_visibility.mustache
change_type: ModificationType.MODIFY
diff: @@ -5,7 +5,11 @@
     </label>
     <select class="tlp-select" name="project_visibility" id="project_visibility" data-test="project_visibility">
         {{# options }}
-            <option value="{{ value }}" {{ selected }}>{{ label }}</option>
+            <option value="{{ value }}"
+                {{# disabled }} disabled {{/ disabled }}
+                {{# title }} title="{{ . }}" {{/ title }}
+                {{ selected }}
+            >{{ label }}</option>
         {{/ options }}
     </select>
 </div>

programming_language: HTML
filename: editgroupinfo.php
change_type: ModificationType.MODIFY
diff: @@ -64,7 +64,8 @@ $project_visibility_presenter_builder = new ProjectVisibilityPresenterBuilder(
     $project_visibility_configuration,
     $service_truncated_mails_retriever,
     new RestrictedUsersProjectCounter(new UserDao()),
-    new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator()
+    new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator(),
+    new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
 );
 
 $csrf_token = new CSRFSynchronizerToken($request->getFromServer('REQUEST_URI'));
@@ -84,7 +85,8 @@ $project_details_controller = new ProjectDetailsController(
     $trove_cat_link_dao,
     $csrf_token,
     TemplateFactory::build(),
-    $project_icons_retriever
+    $project_icons_retriever,
+    new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
 );
 
 $project_details_router = new ProjectDetailsRouter(

programming_language: PHP
filename: ProjectVisibilityOptionsForPresenterGeneratorTest.php
change_type: ModificationType.MODIFY
diff: @@ -23,33 +23,58 @@ declare(strict_types=1);
 namespace Tuleap\Project\Admin;
 
 use Project;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
 
 final class ProjectVisibilityOptionsForPresenterGeneratorTest extends \Tuleap\Test\PHPUnit\TestCase
 {
     public function testGeneratedOptionsWhenRestrictedUsersAreNotAllowed(): void
     {
         $generator = new ProjectVisibilityOptionsForPresenterGenerator();
-        $options   = $generator->generateVisibilityOptions(false, Project::ACCESS_PUBLIC);
+        $options   = $generator->generateVisibilityOptions(false, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PUBLIC);
 
-        $this->assertEqualsCanonicalizing(
+        self::assertEqualsCanonicalizing(
             [Project::ACCESS_PUBLIC, Project::ACCESS_PRIVATE],
             $this->getAvailableAccesses($options)
         );
 
-        $this->assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
+        self::assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
     }
 
     public function testGeneratedOptionsWhenRestrictedUsersAreAllowed(): void
     {
         $generator = new ProjectVisibilityOptionsForPresenterGenerator();
-        $options   = $generator->generateVisibilityOptions(true, Project::ACCESS_PRIVATE_WO_RESTRICTED);
+        $options   = $generator->generateVisibilityOptions(true, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PRIVATE_WO_RESTRICTED);
 
-        $this->assertEqualsCanonicalizing(
+        self::assertEqualsCanonicalizing(
             [Project::ACCESS_PUBLIC, Project::ACCESS_PRIVATE, Project::ACCESS_PRIVATE_WO_RESTRICTED, Project::ACCESS_PUBLIC_UNRESTRICTED],
             $this->getAvailableAccesses($options)
         );
 
-        $this->assertEquals(Project::ACCESS_PRIVATE_WO_RESTRICTED, $this->getSelectedAccess($options));
+        self::assertEquals(Project::ACCESS_PRIVATE_WO_RESTRICTED, $this->getSelectedAccess($options));
+    }
+
+    public function testGeneratedOptionsWhenRestrictedUsersAreAllowedButDisabled(): void
+    {
+        $generator = new ProjectVisibilityOptionsForPresenterGenerator();
+        $options   = $generator->generateVisibilityOptions(true, UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(''), Project::ACCESS_PUBLIC);
+
+        self::assertEqualsCanonicalizing(
+            [Project::ACCESS_PUBLIC, Project::ACCESS_PRIVATE, Project::ACCESS_PRIVATE_WO_RESTRICTED, Project::ACCESS_PUBLIC_UNRESTRICTED],
+            $this->getAvailableAccesses($options)
+        );
+
+        self::assertTrue($this->getPrivateWithoutRestrictedOptionDisabledValue($options));
+    }
+
+    private function getPrivateWithoutRestrictedOptionDisabledValue(array $options): bool
+    {
+        foreach ($options as $option) {
+            if ($option['value'] == Project::ACCESS_PRIVATE_WO_RESTRICTED) {
+                return $option['disabled'];
+            }
+        }
+
+        return false;
     }
 
     private function getAvailableAccesses(array $options): array

programming_language: PHP
filename: UpdateVisibilityCheckerTest.php
change_type: ModificationType.ADD
diff: @@ -0,0 +1,148 @@
+<?php
+/**
+ * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+declare(strict_types=1);
+
+namespace Tuleap\Project\Admin\Visibility;
+
+use ForgeAccess;
+use ForgeConfig;
+use Project;
+use Psr\EventDispatcher\EventDispatcherInterface;
+use Tuleap\ForgeConfigSandbox;
+use Tuleap\Test\Builders\ProjectTestBuilder;
+use Tuleap\Test\Builders\UserTestBuilder;
+use Tuleap\Test\PHPUnit\TestCase;
+
+class UpdateVisibilityCheckerTest extends TestCase
+{
+    use ForgeConfigSandbox;
+
+    public function testVisibilitySwitchIsAllowedIfPlatformDoesNotAllowRestrictedUsers(): void
+    {
+        ForgeConfig::set(ForgeAccess::CONFIG, "whatever");
+        $project = ProjectTestBuilder::aProject()->build();
+
+        self::assertTrue(
+            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, 'whatever')->canSwitch()
+        );
+    }
+
+    public function testVisibilitySwitchIsAllowedIfPlatformAllowRestrictedUsersButVisibilityIsNotPrivateWithoutRestricted(): void
+    {
+        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
+        $project = ProjectTestBuilder::aProject()->build();
+
+        self::assertTrue(
+            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PUBLIC)->canSwitch()
+        );
+
+        self::assertTrue(
+            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE)->canSwitch()
+        );
+
+        self::assertTrue(
+            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PUBLIC_UNRESTRICTED)->canSwitch()
+        );
+    }
+
+    public function testVisibilitySwitchIsAllowedIfPlatformAllowRestrictedUsersVisibilityIsPrivateWithoutRestrictedWithActiveAdminsAndExternalPluginsAllowTheChange(): void
+    {
+        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
+        $project = $this->createMock(Project::class);
+        $project->method('getAdmins')->willReturn([
+            UserTestBuilder::anActiveUser()->build(),
+        ]);
+
+        $checker = new UpdateVisibilityChecker(
+            new class implements EventDispatcherInterface {
+                /**
+                 * @return UpdateVisibilityIsAllowedEvent
+                 */
+                public function dispatch(object $event)
+                {
+                    return new UpdateVisibilityIsAllowedEvent(ProjectTestBuilder::aProject()->build());
+                }
+            }
+        );
+
+        self::assertTrue(
+            $checker->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE_WO_RESTRICTED)->canSwitch()
+        );
+    }
+
+    public function testVisibilitySwitchIsNotAllowedIfPlatformAllowRestrictedUsersVisibilityIsPrivateWithoutRestrictedWithActiveAdminsAndExternalPluginsDoesNotAllowTheChange(): void
+    {
+        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
+        $project = $this->createMock(Project::class);
+        $project->method('getAdmins')->willReturn([
+            UserTestBuilder::anActiveUser()->build(),
+        ]);
+
+        $checker = new UpdateVisibilityChecker(
+            new class implements EventDispatcherInterface {
+                /**
+                 * @return UpdateVisibilityIsAllowedEvent
+                 */
+                public function dispatch(object $event)
+                {
+                    $event = new UpdateVisibilityIsAllowedEvent(ProjectTestBuilder::aProject()->build());
+                    $event->setUpdateVisibilityStatus(
+                        UpdateVisibilityStatus::buildStatusSwitchIsNotAllowed(""),
+                    );
+
+                    return $event;
+                }
+            }
+        );
+
+        self::assertFalse(
+            $checker->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE_WO_RESTRICTED)->canSwitch()
+        );
+    }
+
+    public function testVisibilitySwitchIsNotAllowedIfPlatformAllowRestrictedUsersVisibilityIsPrivateWithoutRestrictedWithoutActiveAdmins(): void
+    {
+        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
+        $project = $this->createMock(Project::class);
+        $project->method('getAdmins')->willReturn([
+            UserTestBuilder::aRestrictedUser()->build(),
+        ]);
+
+        self::assertFalse(
+            $this->buildDefaultUpdateVisibilityChecker()->canUpdateVisibilityRegardingRestrictedUsers($project, Project::ACCESS_PRIVATE_WO_RESTRICTED)->canSwitch()
+        );
+    }
+
+    private function buildDefaultUpdateVisibilityChecker(): UpdateVisibilityChecker
+    {
+        return new UpdateVisibilityChecker(
+            new class implements EventDispatcherInterface {
+                /**
+                 * @return void
+                 */
+                public function dispatch(object $event)
+                {
+                    return;
+                }
+            }
+        );
+    }
+}

programming_language: PHP
filename: ProjectDetailsControllerTest.php
change_type: ModificationType.MODIFY
diff: @@ -26,6 +26,7 @@ namespace phpunit\common\Project\ProjectDetails;
 use CSRFSynchronizerToken;
 use EventManager;
 use Feedback;
+use ForgeAccess;
 use ForgeConfig;
 use HTTPRequest;
 use Mockery;
@@ -39,9 +40,11 @@ use Tuleap\Project\Admin\ProjectDetails\ProjectDetailsController;
 use Tuleap\Project\Admin\ProjectDetails\ProjectDetailsDAO;
 use Tuleap\Project\Admin\ProjectVisibilityPresenterBuilder;
 use Tuleap\Project\Admin\ProjectVisibilityUserConfigurationPermissions;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
 use Tuleap\Project\DescriptionFieldsFactory;
 use Tuleap\Project\Icons\ProjectIconRetriever;
 use Tuleap\Project\Registration\Template\TemplateFactory;
+use Tuleap\Test\Builders\UserTestBuilder;
 use Tuleap\TroveCat\TroveCatLinkDao;
 use UGroupBinding;
 
@@ -115,7 +118,8 @@ class ProjectDetailsControllerTest extends \Tuleap\Test\PHPUnit\TestCase
             $trove_cat_link_dao,
             $this->csrf_token,
             Mockery::mock(TemplateFactory::class),
-            new ProjectIconRetriever()
+            new ProjectIconRetriever(),
+            new UpdateVisibilityChecker($this->event_manager),
         );
 
         $GLOBALS['Response'] = Mockery::mock(BaseLayout::class);
@@ -202,6 +206,54 @@ class ProjectDetailsControllerTest extends \Tuleap\Test\PHPUnit\TestCase
         $this->controller->update($request);
     }
 
+    public function testVisibilityUpdateIsNotValidWhenNotMatchingRestrictedUsersConstraints(): void
+    {
+        ForgeConfig::set('feature_flag_project_icon_display', '1');
+        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
+
+        $this->csrf_token->shouldReceive('check')->once();
+
+        $request = Mockery::mock(HTTPRequest::class);
+        $request->shouldReceive('get')->twice()->withArgs(['form_group_name'])->andReturn('project_name');
+        $request->shouldReceive('get')->twice()->withArgs(['form_shortdesc'])->andReturn('decription');
+        $request->shouldReceive('get')->atLeast()->once()->withArgs(['form-group-name-icon'])->andReturn("");
+        $request->shouldReceive('get')->atLeast()->once()->withArgs(['group_id'])->andReturn(102);
+        $request->shouldReceive('get')->withArgs(['project_visibility'])->andReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
+        $request->shouldReceive('get')->withArgs(['term_of_service'])->andReturn(true);
+        $current_user = $this->createMock(PFUser::class);
+        $request->shouldReceive('getCurrentUser')->atLeast()->once()->andReturn($current_user);
+        $request->shouldReceive('existAndNonEmpty')->atLeast()->once()->andReturnFalse();
+
+        $project = $this->createMock(Project::class);
+        $project->method('getAdmins')->willReturn([
+            UserTestBuilder::aRestrictedUser()->build(),
+        ]);
+        $project->method('getAccess')->willReturn(Project::ACCESS_PUBLIC);
+        $project->method('getID')->willReturn(101);
+        $current_user->method('isAdmin')->with(101)->willReturn(true);
+        $request->shouldReceive('getProject')->twice()->andReturn($project);
+
+        $this->description_fields_factory->shouldReceive('getAllDescriptionFields')->twice()->andReturn([]);
+
+        $this->current_project->shouldReceive('getProjectsDescFieldsValue')->once()->andReturn([]);
+
+        $this->project_details_dao->shouldReceive('updateGroupNameAndDescription')->once();
+        $this->project_history_dao->shouldReceive('groupAddHistory')->once();
+        $this->event_manager->shouldReceive('processEvent')->once();
+        $this->project_visibility_configuration->shouldReceive('canUserConfigureProjectVisibility')->once(
+        )->andReturnTrue();
+        $this->project_visibility_configuration->shouldReceive('canUserConfigureTruncatedMail')->once()->andReturnFalse(
+        );
+
+        $GLOBALS['Response']->shouldReceive('addFeedback')->once()->withArgs(
+            [Feedback::ERROR, _('Cannot switch the project visibility because it will remove every restricted users from the project, and after that no administrator will be left.')]
+        );
+
+        $GLOBALS['Response']->shouldReceive('addFeedback')->once()->withArgs([Feedback::INFO, _('Update successful')]);
+
+        $this->controller->update($request);
+    }
+
     public function testItUpdatesProject(): void
     {
         ForgeConfig::set('feature_flag_project_icon_display', '1');

programming_language: PHP
filename: UserRemoverTest.php
change_type: ModificationType.MODIFY
diff: @@ -22,45 +22,44 @@ namespace Tuleap\Project;
 
 require_once __DIR__ . '/../../../../src/www/include/exit.php';
 
-use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
 use PFUser;
+use PHPUnit\Framework\MockObject\MockObject;
 use Tuleap\GlobalLanguageMock;
 use Tuleap\GlobalResponseMock;
 use Tuleap\Test\Builders\ProjectTestBuilder;
+use Tuleap\Test\Builders\UserTestBuilder;
 
 final class UserRemoverTest extends \Tuleap\Test\PHPUnit\TestCase
 {
-    use MockeryPHPUnitIntegration;
     use GlobalResponseMock;
     use GlobalLanguageMock;
 
     private UserRemover $remover;
-    /**
-     * @var \EventManager|\Mockery\MockInterface
-     */
-    private $event_manager;
-    private $project_manager;
-    private $tv3_tracker_factory;
-    private $dao;
-    private $user_manager;
-    private $project_history_dao;
-    private $ugroup_manager;
+    private MockObject&\EventManager $event_manager;
+    private MockObject&\ProjectManager $project_manager;
+    private MockObject&\ArtifactTypeFactory $tv3_tracker_factory;
+    private MockObject&UserRemoverDao $dao;
+    private MockObject&\UserManager $user_manager;
+    private MockObject&\ProjectHistoryDao $project_history_dao;
+    private MockObject&\UGroupManager $ugroup_manager;
     private \Project $project;
     private PFUser $user;
-    private $tracker_v3;
+    private MockObject&\ArtifactType $tracker_v3;
+    private MockObject&UserPermissionsDao $user_permissions_dao;
 
 
     protected function setUp(): void
     {
         parent::setUp();
 
-        $this->project_manager     = \Mockery::spy(\ProjectManager::class);
-        $this->event_manager       = \Mockery::spy(\EventManager::class);
-        $this->tv3_tracker_factory = \Mockery::spy(\ArtifactTypeFactory::class);
-        $this->dao                 = \Mockery::spy(\Tuleap\Project\UserRemoverDao::class);
-        $this->user_manager        = \Mockery::spy(\UserManager::class);
-        $this->project_history_dao = \Mockery::spy(\ProjectHistoryDao::class);
-        $this->ugroup_manager      = \Mockery::spy(\UGroupManager::class);
+        $this->project_manager      = $this->createMock(\ProjectManager::class);
+        $this->event_manager        = $this->createMock(\EventManager::class);
+        $this->tv3_tracker_factory  = $this->createMock(\ArtifactTypeFactory::class);
+        $this->dao                  = $this->createMock(UserRemoverDao::class);
+        $this->user_manager         = $this->createMock(\UserManager::class);
+        $this->project_history_dao  = $this->createMock(\ProjectHistoryDao::class);
+        $this->ugroup_manager       = $this->createMock(\UGroupManager::class);
+        $this->user_permissions_dao = $this->createMock(UserPermissionsDao::class);
 
         $this->remover = new UserRemover(
             $this->project_manager,
@@ -69,7 +68,8 @@ final class UserRemoverTest extends \Tuleap\Test\PHPUnit\TestCase
             $this->dao,
             $this->user_manager,
             $this->project_history_dao,
-            $this->ugroup_manager
+            $this->ugroup_manager,
+            $this->user_permissions_dao,
         );
 
         $this->project    = ProjectTestBuilder::aProject()->withId(101)->withUnixName("")->withAccess(\Project::ACCESS_PRIVATE)->build();
@@ -77,7 +77,7 @@ final class UserRemoverTest extends \Tuleap\Test\PHPUnit\TestCase
             'language_id' => 'en',
             'user_id' => 102,
         ]);
-        $this->tracker_v3 = \Mockery::spy(\ArtifactType::class);
+        $this->tracker_v3 = $this->createMock(\ArtifactType::class);
     }
 
     public function testItRemovesUserFromProjectMembersAndUgroups(): void
@@ -85,32 +85,72 @@ final class UserRemoverTest extends \Tuleap\Test\PHPUnit\TestCase
         $project_id = 101;
         $user_id    = 102;
 
-        $this->dao->shouldReceive('removeUserFromProject')->once()->andReturns(true);
-        $this->dao->shouldReceive('removeUserFromProjectUgroups')->once()->andReturns(true);
-        $this->tracker_v3->shouldReceive('deleteUser')->once()->with(102)->andReturns(true);
-        $this->project_manager->shouldReceive('getProject')->with(101)->andReturns($this->project);
-        $this->user_manager->shouldReceive('getUserById')->with(102)->andReturns($this->user);
-        $this->ugroup_manager->shouldReceive('getStaticUGroups')->with($this->project)->andReturns([]);
-        $this->tv3_tracker_factory->shouldReceive('getArtifactTypesFromId')->with(101)->andReturns([$this->tracker_v3]);
+        $this->dao->expects(self::once())->method('removeNonAdminUserFromProject')->willReturn(true);
+        $this->dao->expects(self::once())->method('removeUserFromProjectUgroups')->willReturn(true);
+        $this->tracker_v3->expects(self::once())->method('deleteUser')->with(102)->willReturn(true);
+        $this->project_manager->method('getProject')->with(101)->willReturn($this->project);
+        $this->user_manager->method('getUserById')->with(102)->willReturn($this->user);
+        $this->ugroup_manager->method('getStaticUGroups')->with($this->project)->willReturn([]);
+        $this->tv3_tracker_factory->method('getArtifactTypesFromId')->with(101)->willReturn([$this->tracker_v3]);
 
-        $this->project_history_dao->shouldReceive('groupAddHistory')->once();
-        $this->event_manager->shouldReceive('processEvent')->twice();
+        $this->project_history_dao->expects(self::once())->method('groupAddHistory');
+        $this->event_manager->expects(self::exactly(2))->method('processEvent');
 
         $this->remover->removeUserFromProject($project_id, $user_id);
     }
 
+    public function testItForcesRemovalOfRestrictedUserFromProjectAdminAndUgroups(): void
+    {
+        $project = ProjectTestBuilder::aProject()->build();
+        $user    = UserTestBuilder::aRestrictedUser()->withId(102)->build();
+
+        $this->user_manager->method('getUserAnonymous')->willReturn(UserTestBuilder::anAnonymousUser()->build());
+
+        $this->user_permissions_dao->expects(self::once())->method('removeUserFromProjectAdmin');
+        $this->dao->expects(self::once())->method('removeNonAdminUserFromProject')->willReturn(true);
+        $this->dao->expects(self::once())->method('removeUserFromProjectUgroups')->willReturn(true);
+        $this->tracker_v3->expects(self::once())->method('deleteUser')->with(102)->willReturn(true);
+        $this->project_manager->method('getProject')->with(101)->willReturn($this->project);
+        $this->user_manager->method('getUserById')->with(102)->willReturn($this->user);
+        $this->ugroup_manager->method('getStaticUGroups')->with($this->project)->willReturn([]);
+        $this->tv3_tracker_factory->method('getArtifactTypesFromId')->with(101)->willReturn([$this->tracker_v3]);
+
+        $this->project_history_dao->expects(self::once())->method('groupAddHistory');
+        $this->project_history_dao->expects(self::once())->method('addHistory');
+        $this->event_manager->expects(self::exactly(2))->method('processEvent');
+        $this->event_manager->expects(self::once())->method('dispatch');
+
+        $this->remover->forceRemoveAdminRestrictedUserFromProject($project, $user);
+    }
+
+    public function testItDoesNotForceRemovalOfUserFromProjectAdminAndUgroupsIfNotRestricted(): void
+    {
+        $project = ProjectTestBuilder::aProject()->build();
+        $user    = UserTestBuilder::anActiveUser()->withId(102)->build();
+
+        $this->user_permissions_dao->expects(self::never())->method('removeUserFromProjectAdmin');
+        $this->dao->expects(self::never())->method('removeNonAdminUserFromProject');
+        $this->dao->expects(self::never())->method('removeUserFromProjectUgroups');
+        $this->project_history_dao->expects(self::never())->method('groupAddHistory');
+        $this->tracker_v3->expects(self::never())->method('deleteUser');
+        $this->event_manager->expects(self::never())->method('processEvent');
+        $this->event_manager->expects(self::never())->method('dispatch');
+
+        $this->remover->forceRemoveAdminRestrictedUserFromProject($project, $user);
+    }
+
     public function testItDoesNothingIfTheUserIsNotRemovedFromProjectMembers(): void
     {
         $project_id = 101;
         $user_id    = 102;
 
-        $this->project_manager->shouldReceive('getProject')->with(101)->andReturns($this->project);
+        $this->project_manager->method('getProject')->with(101)->willReturn($this->project);
 
-        $this->dao->shouldReceive('removeUserFromProject')->once();
-        $this->dao->shouldReceive('removeUserFromProjectUgroups')->never();
-        $this->project_history_dao->shouldReceive('groupAddHistory')->never();
-        $this->tracker_v3->shouldReceive('deleteUser')->never();
-        $this->event_manager->shouldReceive('processEvent')->never();
+        $this->dao->expects(self::once())->method('removeNonAdminUserFromProject');
+        $this->dao->expects(self::never())->method('removeUserFromProjectUgroups');
+        $this->project_history_dao->expects(self::never())->method('groupAddHistory');
+        $this->tracker_v3->expects(self::never())->method('deleteUser');
+        $this->event_manager->expects(self::never())->method('processEvent');
 
         $this->remover->removeUserFromProject($project_id, $user_id);
     }

programming_language: PHP
filename: SystemEventPROJECTISPRIVATETest.php
change_type: ModificationType.MODIFY
diff: @@ -25,8 +25,6 @@ namespace Tuleap\SystemEvent;
 use EventManager;
 use ForgeAccess;
 use ForgeConfig;
-use Mockery;
-use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
 use PFUser;
 use Project;
 use ProjectManager;
@@ -40,9 +38,26 @@ use UserManager;
 
 final class SystemEventPROJECTISPRIVATETest extends \Tuleap\Test\PHPUnit\TestCase
 {
-    use MockeryPHPUnitIntegration;
     use ForgeConfigSandbox;
 
+    private int $project_id = 102;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
+        ForgeConfig::set(ProjectVisibilityConfigManager::SEND_MAIL_ON_PROJECT_VISIBILITY_CHANGE, false);
+
+        $event_manager = new class extends EventManager {
+            public function processEvent($event, $params = [])
+            {
+                return;
+            }
+        };
+        EventManager::setInstance($event_manager);
+    }
+
     protected function tearDown(): void
     {
         ProjectManager::clearInstance();
@@ -52,66 +67,122 @@ final class SystemEventPROJECTISPRIVATETest extends \Tuleap\Test\PHPUnit\TestCas
 
     public function testRestrictedUsersAreRemovedFromAllUserGroupsWhenProjectBecomesPrivateWithoutRestricted(): void
     {
-        $project_id   = 102;
-        $system_event = new SystemEvent_PROJECT_IS_PRIVATE(
-            1,
-            SystemEvent_PROJECT_IS_PRIVATE::TYPE_PROJECT_IS_PRIVATE,
-            SystemEvent_PROJECT_IS_PRIVATE::APP_OWNER_QUEUE,
-            $project_id . '::1',
-            SystemEvent_PROJECT_IS_PRIVATE::PRIORITY_MEDIUM,
-            SystemEvent_PROJECT_IS_PRIVATE::STATUS_NEW,
-            '',
-            '',
-            '',
-            ''
+        $system_event = $this->buildSystemEvent();
+
+        $user_remover   = $this->createMock(UserRemover::class);
+        $ugroup_manager = $this->createMock(\UGroupManager::class);
+        $system_event->injectDependencies(
+            $user_remover,
+            $ugroup_manager,
+        );
+
+        $project_manager = $this->createMock(ProjectManager::class);
+        ProjectManager::setInstance($project_manager);
+        $project = $this->createMock(Project::class);
+        $project->method('getID')->willReturn($this->project_id);
+        $project_manager->method('getProject')->willReturn($project);
+
+        $project->method('usesCVS')->willReturn(false);
+        $project->method('usesSVN')->willReturn(false);
+
+        $project->method('getAccess')->willReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
+
+        $restricted_member_id = 456;
+        $restricted_admin     = UserTestBuilder::aRestrictedUser()->build();
+        $restricted_member    = UserTestBuilder::aRestrictedUser()->withId($restricted_member_id)->build();
+        $member               = UserTestBuilder::anActiveUser()->build();
+
+        $project->method('getAdmins')->willReturn([$restricted_admin, $member]);
+        $user_remover->expects(self::once())->method('forceRemoveAdminRestrictedUserFromProject')->with($project, $restricted_admin);
+        $project->method('getMembers')->willReturn([$restricted_member, $member]);
+        $user_remover->expects(self::once())->method('removeUserFromProject')->with($this->project_id, $restricted_member_id);
+
+        $restricted_user_in_ugroup_only = UserTestBuilder::aRestrictedUser()->build();
+        $ugroup_with_restricted         = $this->createMock(ProjectUGroup::class);
+        $ugroup_with_restricted->method('getMembers')->willReturn([$restricted_user_in_ugroup_only, $member]);
+        $ugroup_with_restricted->expects(self::once())->method('removeUser')->with(
+            $restricted_user_in_ugroup_only,
+            self::callback(
+                function (PFUser $user) {
+                    return (int) $user->getId() === 0;
+                }
+            )
+        );
+        $ugroup_without_restricted = $this->createMock(ProjectUGroup::class);
+        $ugroup_without_restricted->method('getMembers')->willReturn([$member]);
+        $ugroup_manager->method('getStaticUGroups')->with($project)->willReturn(
+            [$ugroup_with_restricted, $ugroup_without_restricted]
         );
 
-        $user_remover   = Mockery::mock(UserRemover::class);
-        $ugroup_manager = Mockery::mock(\UGroupManager::class);
+        self::assertTrue($system_event->process());
+    }
+
+    public function testRestrictedUsersAreRemovedFromAllUserGroupsWhenProjectBecomesPrivateWithoutRestrictedIfAllAdministratorsAreRestricted(): void
+    {
+        $system_event = $this->buildSystemEvent();
+
+        $user_remover   = $this->createMock(UserRemover::class);
+        $ugroup_manager = $this->createMock(\UGroupManager::class);
         $system_event->injectDependencies(
             $user_remover,
-            $ugroup_manager
+            $ugroup_manager,
         );
 
-        $project_manager = Mockery::mock(ProjectManager::class);
+        $project_manager = $this->createMock(ProjectManager::class);
         ProjectManager::setInstance($project_manager);
-        $project = Mockery::mock(Project::class);
-        $project->shouldReceive('getID')->andReturn($project_id);
-        $project_manager->shouldReceive('getProject')->andReturn($project);
+        $project = $this->createMock(Project::class);
+        $project->method('getID')->willReturn($this->project_id);
+        $project_manager->method('getProject')->willReturn($project);
 
-        $project->shouldReceive('usesCVS')->andReturn(false);
-        $project->shouldReceive('usesSVN')->andReturn(false);
-        ForgeConfig::set(ProjectVisibilityConfigManager::SEND_MAIL_ON_PROJECT_VISIBILITY_CHANGE, false);
-        EventManager::setInstance(Mockery::spy(EventManager::class));
+        $project->method('usesCVS')->willReturn(false);
+        $project->method('usesSVN')->willReturn(false);
 
-        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
-        $project->shouldReceive('getAccess')->andReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
+        $project->method('getAccess')->willReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
 
-        $restricted_member = Mockery::mock(PFUser::class);
-        $restricted_member->shouldReceive('isRestricted')->andReturn(true);
         $restricted_member_id = 456;
-        $restricted_member->shouldReceive('getId')->andReturn($restricted_member_id);
-        $member = UserTestBuilder::anActiveUser()->build();
-        $project->shouldReceive('getMembers')->andReturn([$restricted_member, $member]);
-        $user_remover->shouldReceive('removeUserFromProject')->with($project_id, $restricted_member_id)->once();
+        $restricted_admin     = UserTestBuilder::aRestrictedUser()->build();
+        $restricted_member    = UserTestBuilder::aRestrictedUser()->withId($restricted_member_id)->build();
+        $member               = UserTestBuilder::anActiveUser()->build();
+
+        $project->method('getAdmins')->willReturn([$restricted_admin]);
+        $project->method('getMembers')->willReturn([$restricted_member, $member]);
+
+        $user_remover->expects(self::once())->method('forceRemoveAdminRestrictedUserFromProject')->with($project, $restricted_admin);
+        $user_remover->expects(self::once())->method('removeUserFromProject')->with($this->project_id, $restricted_member_id);
 
         $restricted_user_in_ugroup_only = UserTestBuilder::aRestrictedUser()->build();
-        $ugroup_with_restricted         = Mockery::mock(ProjectUGroup::class);
-        $ugroup_with_restricted->shouldReceive('getMembers')->andReturn([$restricted_user_in_ugroup_only, $member]);
-        $ugroup_with_restricted->shouldReceive('removeUser')->with(
+        $ugroup_with_restricted         = $this->createMock(ProjectUGroup::class);
+        $ugroup_with_restricted->method('getMembers')->willReturn([$restricted_user_in_ugroup_only, $member]);
+        $ugroup_with_restricted->expects(self::once())->method('removeUser')->with(
             $restricted_user_in_ugroup_only,
-            \Mockery::on(
+            self::callback(
                 function (PFUser $user) {
                     return (int) $user->getId() === 0;
                 }
             )
-        )->once();
-        $ugroup_without_restricted = Mockery::mock(ProjectUGroup::class);
-        $ugroup_without_restricted->shouldReceive('getMembers')->andReturn([$member]);
-        $ugroup_manager->shouldReceive('getStaticUGroups')->with($project)->andReturn(
+        );
+        $ugroup_without_restricted = $this->createMock(ProjectUGroup::class);
+        $ugroup_without_restricted->method('getMembers')->willReturn([$member]);
+        $ugroup_manager->method('getStaticUGroups')->with($project)->willReturn(
             [$ugroup_with_restricted, $ugroup_without_restricted]
         );
 
         self::assertTrue($system_event->process());
     }
+
+    private function buildSystemEvent(): SystemEvent_PROJECT_IS_PRIVATE
+    {
+        return new SystemEvent_PROJECT_IS_PRIVATE(
+            1,
+            SystemEvent_PROJECT_IS_PRIVATE::TYPE_PROJECT_IS_PRIVATE,
+            SystemEvent_PROJECT_IS_PRIVATE::APP_OWNER_QUEUE,
+            $this->project_id . '::1',
+            SystemEvent_PROJECT_IS_PRIVATE::PRIORITY_MEDIUM,
+            SystemEvent_PROJECT_IS_PRIVATE::STATUS_NEW,
+            '',
+            '',
+            '',
+            ''
+        );
+    }
 }

programming_language: PHP