CVE-2023-35938: Improper Preservation of Permissions (CWE-281)
value:  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.
CWE 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.
CVSS v3 base score: 7.2
Published: 2023-06-29T20:15Z
Repository: https://github.com/Enalean/tuleap
Language: PHP
Files changed: plugins/ldap/bin/ldap_sync.php, plugins/ldap/include/LDAP_DirectorySynchronization.class.php, plugins/ldap/include/ldapPlugin.php, plugins/project_ownership/include/ProjectAdmin/UpdateVisibilityChecker.php, plugins/project_ownership/include/ProjectOwner/ProjectOwnerDAO.php, plugins/project_ownership/include/ProjectOwner/ProjectOwnerRemover.php, plugins/project_ownership/include/project_ownershipPlugin.php, plugins/project_ownership/site-content/fr_FR/LC_MESSAGES/tuleap-project_ownership.po, plugins/project_ownership/site-content/pt_BR/LC_MESSAGES/tuleap-project_ownership.po, plugins/project_ownership/tests/unit/ProjectAdmin/UpdateVisibilityCheckerTest.php, plugins/project_ownership/tests/unit/ProjectOwner/ProjectOwnerRemoverTest.php, site-content/fr_FR/LC_MESSAGES/tuleap-core.po, site-content/pt_BR/LC_MESSAGES/tuleap-core.po, src/common/Config/ForgeConfig.php, src/common/Project/Admin/ForceRemovalOfRestrictedAdministrator.php, src/common/Project/Admin/ProjectDetails/ProjectDetailsController.php, src/common/Project/Admin/ProjectMembers/ProjectMembersController.php, src/common/Project/Admin/ProjectUGroup/MemberRemovalController.php, src/common/Project/Admin/ProjectVisibilityOptionsForPresenterGenerator.php, src/common/Project/Admin/ProjectVisibilityPresenter.class.php, src/common/Project/Admin/ProjectVisibilityPresenterBuilder.php, src/common/Project/Admin/Visibility/UpdateVisibilityChecker.php, src/common/Project/Admin/Visibility/UpdateVisibilityIsAllowedEvent.php, src/common/Project/Admin/Visibility/UpdateVisibilityStatus.php, src/common/Project/Project.class.php, src/common/Project/ProjectXMLImporter.class.php, src/common/Project/Registration/ProjectRegistrationPresenter.php, src/common/Project/UGroupManager.class.php, src/common/Project/UserRemover.php, src/common/Project/UserRemoverDao.php, src/common/Request/RouteCollector.php, src/common/User/SwitchToPresenterBuilder.php, src/common/admin/ProjectCreation/ProjectVisibility/ProjectVisibilityConfigPresenter.php, src/common/system_event/SystemEventManager.class.php, src/common/system_event/include/SystemEvent_PROJECT_IS_PRIVATE.class.php, src/templates/project/project_visibility.mustache, src/www/project/admin/editgroupinfo.php, tests/unit/common/Project/Admin/ProjectVisibilityOptionsForPresenterGeneratorTest.php, tests/unit/common/Project/Admin/Visibility/UpdateVisibilityCheckerTest.php, tests/unit/common/Project/ProjectDetails/ProjectDetailsControllerTest.php, tests/unit/common/Project/UserRemoverTest.php, tests/unit/common/SystemEvent/SystemEventPROJECTISPRIVATETest.php
Commit date: 2023-06-07 15:30:18 +0200
Commit message: 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

Diff: diff --git a/plugins/ldap/bin/ldap_sync.php b/plugins/ldap/bin/ldap_sync.php
index c1ae6463932c..3c11e02031ad 100644
--- a/plugins/ldap/bin/ldap_sync.php
+++ b/plugins/ldap/bin/ldap_sync.php
@@ -20,10 +20,12 @@
  * along with Codendi; if not, write to the Free Software
  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
  *
  */
 
+use Tuleap\Project\UserPermissionsDao;
+
 ini_set('max_execution_time', 0);
 ini_set('memory_limit', -1);
 
 require_once __DIR__ . '/../../../src/www/include/pre.php';
 
@@ -69,11 +71,12 @@ if ($ldapPlugin instanceof LdapPlugin) {
                 EventManager::instance(),
                 new ArtifactTypeFactory(false),
                 new \Tuleap\Project\UserRemoverDao(),
                 UserManager::instance(),
                 new ProjectHistoryDao(),
-                new UGroupManager()
+                new UGroupManager(),
+                new UserPermissionsDao(),
             );
 
             $ldapCleanUpManager = new LDAP_CleanUpManager($user_remover, $retentionPeriod);
             $ldapCleanUpManager->cleanAll();
         }
diff --git a/plugins/ldap/include/LDAP_DirectorySynchronization.class.php b/plugins/ldap/include/LDAP_DirectorySynchronization.class.php
index 58d099991114..5e53d9f41c32 100755
--- a/plugins/ldap/include/LDAP_DirectorySynchronization.class.php
+++ b/plugins/ldap/include/LDAP_DirectorySynchronization.class.php
@@ -24,10 +24,11 @@
 
 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;
 
 class LDAP_DirectorySynchronization
@@ -217,18 +218,19 @@ class LDAP_DirectorySynchronization
     private function getEventManager()
     {
         return EventManager::instance();
     }
 
-    private function getUserRemover()
+    private function getUserRemover(): UserRemover
     {
         return new UserRemover(
             ProjectManager::instance(),
             EventManager::instance(),
             new ArtifactTypeFactory(false),
             new UserRemoverDao(),
             UserManager::instance(),
             new ProjectHistoryDao(),
-            new UGroupManager()
+            new UGroupManager(),
+            new UserPermissionsDao(),
         );
     }
 }
diff --git a/plugins/ldap/include/ldapPlugin.php b/plugins/ldap/include/ldapPlugin.php
index f50588b4697e..09e30a6177b5 100644
--- a/plugins/ldap/include/ldapPlugin.php
+++ b/plugins/ldap/include/ldapPlugin.php
@@ -44,10 +44,11 @@ use Tuleap\Project\Admin\ProjectMembers\ProjectMembersAdditionalModalCollectionP
 use Tuleap\Project\Admin\ProjectUGroup\BindingAdditionalModalPresenterCollection;
 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;
 use Tuleap\Request\DispatchableWithRequest;
 use Tuleap\SVNCore\Event\GetSVNLoginNameEvent;
@@ -1225,20 +1226,21 @@ class LdapPlugin extends Plugin
             CodendiDataAccess::instance(),
             $this->getUserRemover()
         );
     }
 
-    private function getUserRemover()
+    private function getUserRemover(): UserRemover
     {
         return new UserRemover(
             ProjectManager::instance(),
             EventManager::instance(),
             new ArtifactTypeFactory(false),
             new UserRemoverDao(),
             UserManager::instance(),
             new ProjectHistoryDao(),
-            new UGroupManager()
+            new UGroupManager(),
+            new UserPermissionsDao(),
         );
     }
 
     private function currentRequestIsForProjectUgroupAdmin()
     {
diff --git a/plugins/project_ownership/include/ProjectAdmin/UpdateVisibilityChecker.php b/plugins/project_ownership/include/ProjectAdmin/UpdateVisibilityChecker.php
new file mode 100644
index 000000000000..8c24d667c82d
--- /dev/null
+++ b/plugins/project_ownership/include/ProjectAdmin/UpdateVisibilityChecker.php
@@ -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();
+    }
+}
diff --git a/plugins/project_ownership/include/ProjectOwner/ProjectOwnerDAO.php b/plugins/project_ownership/include/ProjectOwner/ProjectOwnerDAO.php
index 217ef81ce809..9e61144308fd 100644
--- a/plugins/project_ownership/include/ProjectOwner/ProjectOwnerDAO.php
+++ b/plugins/project_ownership/include/ProjectOwner/ProjectOwnerDAO.php
@@ -40,6 +40,17 @@ class ProjectOwnerDAO extends DataAccessObject
         return $this->getDB()->row(
             'SELECT * FROM plugin_project_ownership_project_owner WHERE project_id = ?',
             $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,
+            ],
+        );
+    }
 }
diff --git a/plugins/project_ownership/include/ProjectOwner/ProjectOwnerRemover.php b/plugins/project_ownership/include/ProjectOwner/ProjectOwnerRemover.php
new file mode 100644
index 000000000000..a43e60089c25
--- /dev/null
+++ b/plugins/project_ownership/include/ProjectOwner/ProjectOwnerRemover.php
@@ -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."
+            );
+        }
+    }
+}
diff --git a/plugins/project_ownership/include/project_ownershipPlugin.php b/plugins/project_ownership/include/project_ownershipPlugin.php
index d3ef66b9eb21..eacfcbaeb197 100644
--- a/plugins/project_ownership/include/project_ownershipPlugin.php
+++ b/plugins/project_ownership/include/project_ownershipPlugin.php
@@ -19,20 +19,23 @@
  */
 
 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;
 use Tuleap\ProjectOwnership\ProjectOwner\XMLProjectImportUserCreatorProjectOwnerCleaner;
 use Tuleap\ProjectOwnership\REST\ProjectOwnershipResource;
@@ -210,6 +213,29 @@ class project_ownershipPlugin extends Plugin // phpcs:ignore
     {
         return new ProjectOwnershipSystemEventManager(
             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,
+        );
+    }
 }
diff --git a/plugins/project_ownership/site-content/fr_FR/LC_MESSAGES/tuleap-project_ownership.po b/plugins/project_ownership/site-content/fr_FR/LC_MESSAGES/tuleap-project_ownership.po
index 461434f5fab5..c9a45793434a 100644
--- a/plugins/project_ownership/site-content/fr_FR/LC_MESSAGES/tuleap-project_ownership.po
+++ b/plugins/project_ownership/site-content/fr_FR/LC_MESSAGES/tuleap-project_ownership.po
@@ -13,10 +13,17 @@ msgstr ""
 "X-Generator: Poedit 2.2\n"
 
 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 :"
 
 # php-format
 #, php-format
diff --git a/plugins/project_ownership/site-content/pt_BR/LC_MESSAGES/tuleap-project_ownership.po b/plugins/project_ownership/site-content/pt_BR/LC_MESSAGES/tuleap-project_ownership.po
index 13ac1d4c7a7c..4580218da792 100644
--- a/plugins/project_ownership/site-content/pt_BR/LC_MESSAGES/tuleap-project_ownership.po
+++ b/plugins/project_ownership/site-content/pt_BR/LC_MESSAGES/tuleap-project_ownership.po
@@ -7,10 +7,15 @@ msgstr ""
 "Plural-Forms: nplurals=2; plural=(n > 1);\n"
 
 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 ""
 
 #, php-format
 msgid "Failed to send notification to user %s ."
diff --git a/plugins/project_ownership/tests/unit/ProjectAdmin/UpdateVisibilityCheckerTest.php b/plugins/project_ownership/tests/unit/ProjectAdmin/UpdateVisibilityCheckerTest.php
new file mode 100644
index 000000000000..ed492bc4755c
--- /dev/null
+++ b/plugins/project_ownership/tests/unit/ProjectAdmin/UpdateVisibilityCheckerTest.php
@@ -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()
+        );
+    }
+}
diff --git a/plugins/project_ownership/tests/unit/ProjectOwner/ProjectOwnerRemoverTest.php b/plugins/project_ownership/tests/unit/ProjectOwner/ProjectOwnerRemoverTest.php
new file mode 100644
index 000000000000..2d9a9aa2436b
--- /dev/null
+++ b/plugins/project_ownership/tests/unit/ProjectOwner/ProjectOwnerRemoverTest.php
@@ -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(),
+        );
+    }
+}
diff --git a/site-content/fr_FR/LC_MESSAGES/tuleap-core.po b/site-content/fr_FR/LC_MESSAGES/tuleap-core.po
index 4947e5c8e04f..df6b0266f00a 100644
--- a/site-content/fr_FR/LC_MESSAGES/tuleap-core.po
+++ b/site-content/fr_FR/LC_MESSAGES/tuleap-core.po
@@ -898,10 +898,18 @@ msgid "Cannot remove user from group"
 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é."
 
 msgid "Cannot update users group."
 msgstr "Impossible de mettre à jour le groupe utilisateur"
@@ -2785,11 +2793,11 @@ msgstr "Privé"
 
 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"
 
 msgid "Private projects can't make their repository public."
diff --git a/site-content/pt_BR/LC_MESSAGES/tuleap-core.po b/site-content/pt_BR/LC_MESSAGES/tuleap-core.po
index 51c6d72d8ef1..b7a8c19a27ff 100644
--- a/site-content/pt_BR/LC_MESSAGES/tuleap-core.po
+++ b/site-content/pt_BR/LC_MESSAGES/tuleap-core.po
@@ -884,10 +884,15 @@ msgid "Cannot remove user from group"
 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."
 
 msgid "Cannot update users group."
 msgstr "Não é possível atualizar o grupo de usuários."
diff --git a/src/common/Config/ForgeConfig.php b/src/common/Config/ForgeConfig.php
index 1a2f79ad3d3f..88c8cb8107eb 100644
--- a/src/common/Config/ForgeConfig.php
+++ b/src/common/Config/ForgeConfig.php
@@ -298,11 +298,11 @@ class ForgeConfig
     public static function areAnonymousAllowed()
     {
         return self::get(ForgeAccess::CONFIG) === ForgeAccess::ANONYMOUS;
     }
 
-    public static function areRestrictedUsersAllowed()
+    public static function areRestrictedUsersAllowed(): bool
     {
         return self::get(ForgeAccess::CONFIG) === ForgeAccess::RESTRICTED;
     }
 
     public static function getApplicationUserLogin()
diff --git a/src/common/Project/Admin/ForceRemovalOfRestrictedAdministrator.php b/src/common/Project/Admin/ForceRemovalOfRestrictedAdministrator.php
new file mode 100644
index 000000000000..7987ae859bbb
--- /dev/null
+++ b/src/common/Project/Admin/ForceRemovalOfRestrictedAdministrator.php
@@ -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,
+    ) {
+    }
+}
diff --git a/src/common/Project/Admin/ProjectDetails/ProjectDetailsController.php b/src/common/Project/Admin/ProjectDetails/ProjectDetailsController.php
index f64df0b94866..9efc13cbf843 100644
--- a/src/common/Project/Admin/ProjectDetails/ProjectDetailsController.php
+++ b/src/common/Project/Admin/ProjectDetails/ProjectDetailsController.php
@@ -39,10 +39,11 @@ use ProjectManager;
 use Rule_ProjectFullName;
 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;
 use Tuleap\Project\Icons\InvalidProjectIconException;
 use Tuleap\Project\Icons\ProjectIconChecker;
@@ -124,10 +125,11 @@ class ProjectDetailsController
         UGroupBinding $ugroup_binding,
         TroveCatLinkDao $trove_cat_link_dao,
         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;
         $this->project_details_dao                  = $project_details_dao;
         $this->project_manager                      = $project_manager;
@@ -505,13 +507,25 @@ class ProjectDetailsController
     private function updateProjectVisibility(PFUser $user, Project $project, HTTPRequest $request)
     {
         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"));
                 }
             }
         }
diff --git a/src/common/Project/Admin/ProjectMembers/ProjectMembersController.php b/src/common/Project/Admin/ProjectMembers/ProjectMembersController.php
index 8ecdf4ef6e96..98c1041294cf 100644
--- a/src/common/Project/Admin/ProjectMembers/ProjectMembersController.php
+++ b/src/common/Project/Admin/ProjectMembers/ProjectMembersController.php
@@ -167,11 +167,12 @@ class ProjectMembersController implements DispatchableWithRequest, DispatchableW
                 $event_manager,
                 new ArtifactTypeFactory(false),
                 new UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                $ugroup_manager
+                $ugroup_manager,
+                new UserPermissionsDao(),
             ),
             $event_manager,
             $ugroup_manager,
             new UserImport(
                 $user_manager,
diff --git a/src/common/Project/Admin/ProjectUGroup/MemberRemovalController.php b/src/common/Project/Admin/ProjectUGroup/MemberRemovalController.php
index 7a173ba45d57..60927bc47055 100644
--- a/src/common/Project/Admin/ProjectUGroup/MemberRemovalController.php
+++ b/src/common/Project/Admin/ProjectUGroup/MemberRemovalController.php
@@ -124,11 +124,12 @@ class MemberRemovalController implements DispatchableWithRequest
                 $event_manager,
                 new ArtifactTypeFactory(false),
                 new UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                $ugroup_manager
+                $ugroup_manager,
+                new UserPermissionsDao(),
             ),
             UGroupRouter::getCSRFTokenSynchronizer()
         );
     }
 
diff --git a/src/common/Project/Admin/ProjectVisibilityOptionsForPresenterGenerator.php b/src/common/Project/Admin/ProjectVisibilityOptionsForPresenterGenerator.php
index 7270d7b5272e..7c3ca480d87f 100644
--- a/src/common/Project/Admin/ProjectVisibilityOptionsForPresenterGenerator.php
+++ b/src/common/Project/Admin/ProjectVisibilityOptionsForPresenterGenerator.php
@@ -21,53 +21,67 @@
 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) {
             return [
                 [
                     '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'    => '',
                 ],
             ];
         }
         return [
             [
                 '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'    => '',
             ],
         ];
     }
 }
diff --git a/src/common/Project/Admin/ProjectVisibilityPresenter.class.php b/src/common/Project/Admin/ProjectVisibilityPresenter.class.php
index faeb77949f6e..311d4457d68e 100644
--- a/src/common/Project/Admin/ProjectVisibilityPresenter.class.php
+++ b/src/common/Project/Admin/ProjectVisibilityPresenter.class.php
@@ -19,10 +19,11 @@
 
 namespace Tuleap\Project\Admin;
 
 use BaseLanguage;
 use Codendi_HTMLPurifier;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
 
 class ProjectVisibilityPresenter
 {
     /**
      * @var array
@@ -52,16 +53,17 @@ class ProjectVisibilityPresenter
      */
     public $number_of_restricted_users_in_project;
 
     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'
         );
         $this->general_warning_message          = $language->getText(
@@ -74,12 +76,14 @@ 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;
     }
 }
diff --git a/src/common/Project/Admin/ProjectVisibilityPresenterBuilder.php b/src/common/Project/Admin/ProjectVisibilityPresenterBuilder.php
index a45d07831c0f..0fb76e80d24d 100644
--- a/src/common/Project/Admin/ProjectVisibilityPresenterBuilder.php
+++ b/src/common/Project/Admin/ProjectVisibilityPresenterBuilder.php
@@ -24,11 +24,13 @@
 
 namespace Tuleap\Project\Admin;
 
 use ForgeConfig;
 use HTTPRequest;
+use Project;
 use ProjectTruncatedEmailsPresenter;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
 use Tuleap\Project\ProjectAccessPresenter;
 
 class ProjectVisibilityPresenterBuilder
 {
     /**
@@ -52,10 +54,11 @@ class ProjectVisibilityPresenterBuilder
     public function __construct(
         ProjectVisibilityUserConfigurationPermissions $project_visibility_configuration,
         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;
         $this->restricted_users_project_counter     = $restricted_users_project_counter;
         $this->project_visibility_options_generator = $project_visibility_options_generator;
@@ -66,10 +69,14 @@ class ProjectVisibilityPresenterBuilder
         $project              = $request->getProject();
         $current_user         = $request->getCurrentUser();
         $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
         );
 
diff --git a/src/common/Project/Admin/Visibility/UpdateVisibilityChecker.php b/src/common/Project/Admin/Visibility/UpdateVisibilityChecker.php
new file mode 100644
index 000000000000..e8b91599e335
--- /dev/null
+++ b/src/common/Project/Admin/Visibility/UpdateVisibilityChecker.php
@@ -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;
+    }
+}
diff --git a/src/common/Project/Admin/Visibility/UpdateVisibilityIsAllowedEvent.php b/src/common/Project/Admin/Visibility/UpdateVisibilityIsAllowedEvent.php
new file mode 100644
index 000000000000..209870043696
--- /dev/null
+++ b/src/common/Project/Admin/Visibility/UpdateVisibilityIsAllowedEvent.php
@@ -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;
+    }
+}
diff --git a/src/common/Project/Admin/Visibility/UpdateVisibilityStatus.php b/src/common/Project/Admin/Visibility/UpdateVisibilityStatus.php
new file mode 100644
index 000000000000..aa39ae39adf3
--- /dev/null
+++ b/src/common/Project/Admin/Visibility/UpdateVisibilityStatus.php
@@ -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;
+    }
+}
diff --git a/src/common/Project/Project.class.php b/src/common/Project/Project.class.php
index 6334ae7d2bc1..e3dfc028f69d 100644
--- a/src/common/Project/Project.class.php
+++ b/src/common/Project/Project.class.php
@@ -515,13 +515,13 @@ class Project extends Group implements PFO_Project  // phpcs:ignore PSR1.Classes
     {
         return new UGroupManager();
     }
 
     /**
-     * @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();
         }
         return $ugm->getDynamicUGroupsMembers(ProjectUGroup::PROJECT_ADMIN, $this->getID());
diff --git a/src/common/Project/ProjectXMLImporter.class.php b/src/common/Project/ProjectXMLImporter.class.php
index fc158797785a..41e477a8c6a9 100644
--- a/src/common/Project/ProjectXMLImporter.class.php
+++ b/src/common/Project/ProjectXMLImporter.class.php
@@ -42,10 +42,11 @@ use Tuleap\Project\DescriptionFieldsFactory;
 use Tuleap\Project\Event\ProjectXMLImportPreChecksEvent;
 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;
 use Tuleap\Project\XML\Import\ImportConfig;
 use Tuleap\Project\XML\Import\ImportNotValidException;
@@ -191,11 +192,12 @@ class ProjectXMLImporter //phpcs:ignore PSR1.Classes.ClassDeclaration.MissingNam
                 $event_manager,
                 new ArtifactTypeFactory(false),
                 new UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                new UGroupManager()
+                new UGroupManager(),
+                new UserPermissionsDao(),
             ),
             ProjectMemberAdderWithoutStatusCheckAndNotifications::build(),
             $project_creator,
             new UploadedLinksUpdater(new UploadedLinksDao(), FRSLog::instance()),
             new ProjectDashboardXMLImporter(
diff --git a/src/common/Project/Registration/ProjectRegistrationPresenter.php b/src/common/Project/Registration/ProjectRegistrationPresenter.php
index 1025b024ee45..1f3022bd30b0 100644
--- a/src/common/Project/Registration/ProjectRegistrationPresenter.php
+++ b/src/common/Project/Registration/ProjectRegistrationPresenter.php
@@ -92,11 +92,11 @@ class ProjectRegistrationPresenter
         array $company_templates,
         array $tuleap_templates,
         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,
             true
         );
diff --git a/src/common/Project/UGroupManager.class.php b/src/common/Project/UGroupManager.class.php
index 41a9bfe28e99..2fe839052d86 100644
--- a/src/common/Project/UGroupManager.class.php
+++ b/src/common/Project/UGroupManager.class.php
@@ -602,21 +602,19 @@ class UGroupManager implements \Tuleap\Project\UGroupRetriever // phpcs:ignore P
             default:
                 $user_group->removeUser($user, $project_administrator);
         }
     }
 
-    /**
-     * @return UserRemover
-     */
-    private function getUserRemover()
+    private function getUserRemover(): UserRemover
     {
         return new UserRemover(
             ProjectManager::instance(),
             $this->getEventManager(),
             new ArtifactTypeFactory(false),
             new UserRemoverDao(),
             UserManager::instance(),
             new ProjectHistoryDao(),
-            new UGroupManager()
+            new UGroupManager(),
+            new UserPermissionsDao(),
         );
     }
 }
diff --git a/src/common/Project/UserRemover.php b/src/common/Project/UserRemover.php
index 68771d671624..cfb01d79709a 100644
--- a/src/common/Project/UserRemover.php
+++ b/src/common/Project/UserRemover.php
@@ -22,10 +22,12 @@ namespace Tuleap\Project;
 
 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;
 use UGroupManager;
 
@@ -72,25 +74,51 @@ class UserRemover
         ArtifactTypeFactory $tv3_tracker_factory,
         UserRemoverDao $dao,
         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;
         $this->tv3_tracker_factory = $tv3_tracker_factory;
         $this->dao                 = $dao;
         $this->user_manager        = $user_manager;
         $this->project_history_dao = $project_history_dao;
         $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')
             );
 
diff --git a/src/common/Project/UserRemoverDao.php b/src/common/Project/UserRemoverDao.php
index 890286bd0456..6122b60434bf 100644
--- a/src/common/Project/UserRemoverDao.php
+++ b/src/common/Project/UserRemoverDao.php
@@ -22,11 +22,11 @@ namespace Tuleap\Project;
 
 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);
         $admin_flag = $this->da->quoteSmart('A');
 
diff --git a/src/common/Request/RouteCollector.php b/src/common/Request/RouteCollector.php
index a557f1e59be0..d63058223443 100644
--- a/src/common/Request/RouteCollector.php
+++ b/src/common/Request/RouteCollector.php
@@ -176,10 +176,11 @@ use Tuleap\Project\Routing\ProjectRetrieverMiddleware;
 use Tuleap\Project\Service\AddController;
 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;
 use Tuleap\REST\UserManager;
 use Tuleap\ServerHostname;
@@ -581,11 +582,12 @@ class RouteCollector
                 EventManager::instance(),
                 new ArtifactTypeFactory(false),
                 new \Tuleap\Project\UserRemoverDao(),
                 $user_manager,
                 new ProjectHistoryDao(),
-                new UGroupManager()
+                new UGroupManager(),
+                new UserPermissionsDao(),
             ),
             new ProjectAdministratorsIncludingDelegationDAO(),
             new SapiEmitter()
         );
     }
diff --git a/src/common/User/SwitchToPresenterBuilder.php b/src/common/User/SwitchToPresenterBuilder.php
index a8c2178f744d..87fa74b23d64 100644
--- a/src/common/User/SwitchToPresenterBuilder.php
+++ b/src/common/User/SwitchToPresenterBuilder.php
@@ -41,11 +41,11 @@ final class SwitchToPresenterBuilder
 
         $user = $current_user->user;
 
         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(),
         );
     }
diff --git a/src/common/admin/ProjectCreation/ProjectVisibility/ProjectVisibilityConfigPresenter.php b/src/common/admin/ProjectCreation/ProjectVisibility/ProjectVisibilityConfigPresenter.php
index 10e0571691d6..b9f33a828d5e 100644
--- a/src/common/admin/ProjectCreation/ProjectVisibility/ProjectVisibilityConfigPresenter.php
+++ b/src/common/admin/ProjectCreation/ProjectVisibility/ProjectVisibilityConfigPresenter.php
@@ -21,10 +21,11 @@
 namespace Tuleap\admin\ProjectCreation\ProjectVisibility;
 
 use ForgeConfig;
 use Tuleap\Admin\ProjectCreationNavBarPresenter;
 use Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator;
+use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
 
 class ProjectVisibilityConfigPresenter
 {
     /**
      * @var ProjectCreationNavBarPresenter
@@ -61,9 +62,10 @@ class ProjectVisibilityConfigPresenter
         $this->csrf_token                             = $csrf_token;
         $this->project_admin_can_choose               = (bool) ForgeConfig::get(ProjectVisibilityConfigManager::PROJECT_ADMIN_CAN_CHOOSE_VISIBILITY);
         $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
         );
     }
 }
diff --git a/src/common/system_event/SystemEventManager.class.php b/src/common/system_event/SystemEventManager.class.php
index 78ee73c7e4dd..3446f0db90e9 100644
--- a/src/common/system_event/SystemEventManager.class.php
+++ b/src/common/system_event/SystemEventManager.class.php
@@ -18,10 +18,11 @@
  * You should have received a copy of the GNU General Public License
  * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
  */
 
 use Tuleap\admin\ProjectEdit\ProjectStatusUpdate;
+use Tuleap\Project\UserPermissionsDao;
 use Tuleap\Project\UserRemover;
 use Tuleap\Project\UserRemoverDao;
 use Tuleap\SVNCore\Event\UpdateProjectAccessFilesScheduler;
 use Tuleap\SVNCore\Event\UpdateProjectAccessFileSystemEvent;
 use Tuleap\SVNCore\SVNAuthenticationCacheInvalidator;
@@ -486,11 +487,12 @@ class SystemEventManager
                         EventManager::instance(),
                         new ArtifactTypeFactory(false),
                         new UserRemoverDao(),
                         $user_manager,
                         new ProjectHistoryDao(),
-                        new UGroupManager()
+                        new UGroupManager(),
+                        new UserPermissionsDao(),
                     ),
                 ];
                 break;
             case SystemEvent::TYPE_SYSTEM_CHECK:
             case SystemEvent::TYPE_EDIT_SSH_KEYS:
@@ -530,11 +532,12 @@ class SystemEventManager
                         EventManager::instance(),
                         new ArtifactTypeFactory(false),
                         new UserRemoverDao(),
                         UserManager::instance(),
                         new ProjectHistoryDao(),
-                        $ugroup_manager
+                        $ugroup_manager,
+                        new UserPermissionsDao(),
                     ),
                     $ugroup_manager,
                 ];
                 break;
             case SystemEvent::TYPE_PROJECT_ACTIVE:
diff --git a/src/common/system_event/include/SystemEvent_PROJECT_IS_PRIVATE.class.php b/src/common/system_event/include/SystemEvent_PROJECT_IS_PRIVATE.class.php
index dd352dac40f3..639a048730c0 100644
--- a/src/common/system_event/include/SystemEvent_PROJECT_IS_PRIVATE.class.php
+++ b/src/common/system_event/include/SystemEvent_PROJECT_IS_PRIVATE.class.php
@@ -24,22 +24,15 @@
 use Tuleap\admin\ProjectCreation\ProjectVisibility\ProjectVisibilityConfigManager;
 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,
         UGroupManager $ugroup_manager,
     ) {
@@ -113,13 +106,27 @@ class SystemEvent_PROJECT_IS_PRIVATE extends SystemEvent
         return true;
     }
 
     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()) {
                 $this->user_remover->removeUserFromProject($project->getID(), $project_member->getId());
             }
diff --git a/src/templates/project/project_visibility.mustache b/src/templates/project/project_visibility.mustache
index 1bf7244e156b..61082a477390 100644
--- a/src/templates/project/project_visibility.mustache
+++ b/src/templates/project/project_visibility.mustache
@@ -3,11 +3,15 @@
         {{ project_visibility_label }}
         <i class="fa fa-asterisk"></i>
     </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>
 <br>
 {{# platform_allows_restricted }}
diff --git a/src/www/project/admin/editgroupinfo.php b/src/www/project/admin/editgroupinfo.php
index 880419dd79b3..5f953a652ec7 100644
--- a/src/www/project/admin/editgroupinfo.php
+++ b/src/www/project/admin/editgroupinfo.php
@@ -62,11 +62,12 @@ $ugroup_binding = new UGroupBinding(
 
 $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'));
 
 $project_icons_retriever = new ProjectIconRetriever();
@@ -82,11 +83,12 @@ $project_details_controller = new ProjectDetailsController(
     $project_visibility_configuration,
     $ugroup_binding,
     $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(
     $project_details_controller
 );
diff --git a/tests/unit/common/Project/Admin/ProjectVisibilityOptionsForPresenterGeneratorTest.php b/tests/unit/common/Project/Admin/ProjectVisibilityOptionsForPresenterGeneratorTest.php
index d40ae3554f5a..0939a99b115a 100644
--- a/tests/unit/common/Project/Admin/ProjectVisibilityOptionsForPresenterGeneratorTest.php
+++ b/tests/unit/common/Project/Admin/ProjectVisibilityOptionsForPresenterGeneratorTest.php
@@ -21,37 +21,62 @@
 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
     {
         $accesses = [];
diff --git a/tests/unit/common/Project/Admin/Visibility/UpdateVisibilityCheckerTest.php b/tests/unit/common/Project/Admin/Visibility/UpdateVisibilityCheckerTest.php
new file mode 100644
index 000000000000..9d464e7b2873
--- /dev/null
+++ b/tests/unit/common/Project/Admin/Visibility/UpdateVisibilityCheckerTest.php
@@ -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;
+                }
+            }
+        );
+    }
+}
diff --git a/tests/unit/common/Project/ProjectDetails/ProjectDetailsControllerTest.php b/tests/unit/common/Project/ProjectDetails/ProjectDetailsControllerTest.php
index 6b5b5f48ee68..b8da883e60d3 100644
--- a/tests/unit/common/Project/ProjectDetails/ProjectDetailsControllerTest.php
+++ b/tests/unit/common/Project/ProjectDetails/ProjectDetailsControllerTest.php
@@ -24,10 +24,11 @@ declare(strict_types=1);
 namespace phpunit\common\Project\ProjectDetails;
 
 use CSRFSynchronizerToken;
 use EventManager;
 use Feedback;
+use ForgeAccess;
 use ForgeConfig;
 use HTTPRequest;
 use Mockery;
 use PFUser;
 use Project;
@@ -37,13 +38,15 @@ use Tuleap\ForgeConfigSandbox;
 use Tuleap\Layout\BaseLayout;
 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;
 
 class ProjectDetailsControllerTest extends \Tuleap\Test\PHPUnit\TestCase
 {
@@ -113,11 +116,12 @@ class ProjectDetailsControllerTest extends \Tuleap\Test\PHPUnit\TestCase
             $this->project_visibility_configuration,
             $ugroup_binding,
             $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);
     }
 
@@ -200,10 +204,58 @@ class ProjectDetailsControllerTest extends \Tuleap\Test\PHPUnit\TestCase
         $GLOBALS['Response']->shouldReceive('addFeedback')->once()->withArgs([Feedback::INFO, _('Update successful')]);
 
         $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');
 
         $this->csrf_token->shouldReceive('check')->once();
diff --git a/tests/unit/common/Project/UserRemoverTest.php b/tests/unit/common/Project/UserRemoverTest.php
index 49390d442770..77475f2083d5 100644
--- a/tests/unit/common/Project/UserRemoverTest.php
+++ b/tests/unit/common/Project/UserRemoverTest.php
@@ -20,98 +20,138 @@
 
 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,
             $this->event_manager,
             $this->tv3_tracker_factory,
             $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();
         $this->user       = new PFUser([
             'language_id' => 'en',
             'user_id' => 102,
         ]);
-        $this->tracker_v3 = \Mockery::spy(\ArtifactType::class);
+        $this->tracker_v3 = $this->createMock(\ArtifactType::class);
     }
 
     public function testItRemovesUserFromProjectMembersAndUgroups(): void
     {
         $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);
     }
 }
diff --git a/tests/unit/common/SystemEvent/SystemEventPROJECTISPRIVATETest.php b/tests/unit/common/SystemEvent/SystemEventPROJECTISPRIVATETest.php
index f3676a247d2e..bf2a1d782f14 100644
--- a/tests/unit/common/SystemEvent/SystemEventPROJECTISPRIVATETest.php
+++ b/tests/unit/common/SystemEvent/SystemEventPROJECTISPRIVATETest.php
@@ -23,12 +23,10 @@ declare(strict_types=1);
 namespace Tuleap\SystemEvent;
 
 use EventManager;
 use ForgeAccess;
 use ForgeConfig;
-use Mockery;
-use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
 use PFUser;
 use Project;
 use ProjectManager;
 use ProjectUGroup;
 use SystemEvent_PROJECT_IS_PRIVATE;
@@ -38,80 +36,153 @@ use Tuleap\Project\UserRemover;
 use Tuleap\Test\Builders\UserTestBuilder;
 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();
         EventManager::clearInstance();
         UserManager::clearInstance();
     }
 
     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,
+            '',
+            '',
+            '',
+            ''
+        );
+    }
 }

Vulnerable code:                 new UGroupManager()
    private function getUserRemover()
            new UGroupManager()
    private function getUserRemover()
            new UGroupManager()
msgstr "Privé incl. les utilisateurs restreints"
    public static function areRestrictedUsersAllowed()
                    $this->project_manager->setAccess($project, $request->get('project_visibility'));
                    $this->project_manager->clear($project->getID());
                    $this->ugroup_binding->reloadUgroupBindingInProject($project);
                $ugroup_manager
                $ugroup_manager
     * @psalm-return array<array{value: string, label: string, selected: string}>
        $platform_allows_restricted,
        $project_visibility,
        $this->platform_allows_restricted       = (bool) $platform_allows_restricted;
        $this->options                               = $project_visibility_options_generator->generateVisibilityOptions(
     * @return array of User admin of the project
    public function getAdmins(?UGroupManager $ugm = null)
                new UGroupManager()
        $this->are_restricted_users_allowed = (bool) ForgeConfig::areRestrictedUsersAllowed();
    /**
     * @return UserRemover
     */
    private function getUserRemover()
            new UGroupManager()
        if (! $this->dao->removeUserFromProject($project_id, $user_id)) {
    public function removeUserFromProject($project_id, $user_id)
                new UGroupManager()
            (bool) \ForgeConfig::areRestrictedUsersAllowed(),
                        new UGroupManager()
                        $ugroup_manager
*
    /**
     * @var UserRemover
     */
    private $user_remover;
    /**
     * @var UGroupManager
     */
    private $ugroup_manager;
        if (! ForgeConfig::areRestrictedUsersAllowed() || $project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED) {
            <option value="{{ value }}" {{ selected }}>{{ label }}</option>
    new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator()
    $project_icons_retriever
        $options   = $generator->generateVisibilityOptions(false, Project::ACCESS_PUBLIC);
        $this->assertEqualsCanonicalizing(
        $this->assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
        $options   = $generator->generateVisibilityOptions(true, Project::ACCESS_PRIVATE_WO_RESTRICTED);
        $this->assertEqualsCanonicalizing(
        $this->assertEquals(Project::ACCESS_PRIVATE_WO_RESTRICTED, $this->getSelectedAccess($options));
            new ProjectIconRetriever()
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
    use MockeryPHPUnitIntegration;
    /**
     * @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 $tracker_v3;
        $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->ugroup_manager
        $this->tracker_v3 = \Mockery::spy(\ArtifactType::class);
        $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->project_history_dao->shouldReceive('groupAddHistory')->once();
        $this->event_manager->shouldReceive('processEvent')->twice();
        $this->project_manager->shouldReceive('getProject')->with(101)->andReturns($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();
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
    use MockeryPHPUnitIntegration;
        $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,
            '',
            '',
            '',
            ''
        $user_remover   = Mockery::mock(UserRemover::class);
        $ugroup_manager = Mockery::mock(\UGroupManager::class);
            $ugroup_manager
        $project_manager = Mockery::mock(ProjectManager::class);
        $project = Mockery::mock(Project::class);
        $project->shouldReceive('getID')->andReturn($project_id);
        $project_manager->shouldReceive('getProject')->andReturn($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));
        ForgeConfig::set(ForgeAccess::CONFIG, ForgeAccess::RESTRICTED);
        $project->shouldReceive('getAccess')->andReturn(Project::ACCESS_PRIVATE_WO_RESTRICTED);
        $restricted_member = Mockery::mock(PFUser::class);
        $restricted_member->shouldReceive('isRestricted')->andReturn(true);
        $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();
        $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(
            \Mockery::on(
        )->once();
        $ugroup_without_restricted = Mockery::mock(ProjectUGroup::class);
        $ugroup_without_restricted->shouldReceive('getMembers')->andReturn([$member]);
        $ugroup_manager->shouldReceive('getStaticUGroups')->with($project)->andReturn(
Fixed code: use Tuleap\Project\UserPermissionsDao;

                new UGroupManager(),
                new UserPermissionsDao(),
use Tuleap\Project\UserPermissionsDao;
    private function getUserRemover(): UserRemover
            new UGroupManager(),
            new UserPermissionsDao(),
use Tuleap\Project\UserPermissionsDao;
    private function getUserRemover(): UserRemover
            new UGroupManager(),
            new UserPermissionsDao(),
<?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();
    }
}

    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,
            ],
        );
    }
<?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."
            );
        }
    }
}
use Tuleap\Project\Admin\ForceRemovalOfRestrictedAdministrator;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityIsAllowedEvent;
use Tuleap\ProjectOwnership\ProjectOwner\ProjectOwnerRemover;

    #[\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,
        );
    }
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 ""
"Cannot switch the project visibility because it will remove the project "
"owner which is restricted."
msgstr ""

<?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()
        );
    }
}
<?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(),
        );
    }
}
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."

msgstr "Privé, incluant les utilisateurs restreints"
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 ""

    public static function areRestrictedUsersAllowed(): bool
<?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,
    ) {
    }
}
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
        private readonly UpdateVisibilityChecker $update_visibility_checker,
                    $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(),
                        );
                    }
                $ugroup_manager,
                new UserPermissionsDao(),
                $ugroup_manager,
                new UserPermissionsDao(),
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
     * @psalm-return array<array{value: string, label: string, selected: string, disabled: bool, title: string}>
        UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
                    'disabled' => ! $private_without_restricted_visibility_switch_status->canSwitch(),
                    'title'    => ! $private_without_restricted_visibility_switch_status->canSwitch() ? $private_without_restricted_visibility_switch_status->getReason() : '',
                    'disabled' => false,
                    'title'    => '',
                    'disabled' => false,
                    'title'    => '',
                    'disabled' => false,
                    'title'    => '',
                'disabled' => false,
                'title'    => '',
                'disabled' => false,
                'title'    => '',
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
        bool $platform_allows_restricted,
        UpdateVisibilityStatus $private_without_restricted_visibility_switch_status,
        string $project_visibility,
        $this->platform_allows_restricted       = $platform_allows_restricted;
        $this->options = $project_visibility_options_generator->generateVisibilityOptions(
            $private_without_restricted_visibility_switch_status,

use Project;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
        private readonly UpdateVisibilityChecker $update_visibility_checker,
            $this->update_visibility_checker->canUpdateVisibilityRegardingRestrictedUsers(
                $project,
                Project::ACCESS_PRIVATE_WO_RESTRICTED,
            ),
<?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;
    }
}
<?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;
    }
}
<?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;
    }
}
     * @return PFUser[] of User admin of the project
    public function getAdmins(?UGroupManager $ugm = null): array
use Tuleap\Project\UserPermissionsDao;
                new UGroupManager(),
                new UserPermissionsDao(),
        $this->are_restricted_users_allowed = ForgeConfig::areRestrictedUsersAllowed();
    private function getUserRemover(): UserRemover
            new UGroupManager(),
            new UserPermissionsDao(),
use Tuleap\Project\Admin\ForceRemovalOfRestrictedAdministrator;
use Tuleap\Project\UGroups\Membership\DynamicUGroups\ProjectAdminHistoryEntry;
        private readonly UserPermissionsDao $user_permissions_dao,
    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(),
        );
    }

        if (! $this->dao->removeNonAdminUserFromProject($project_id, $user_id)) {
    public function removeNonAdminUserFromProject($project_id, $user_id): bool
use Tuleap\Project\UserPermissionsDao;
                new UGroupManager(),
                new UserPermissionsDao(),
            \ForgeConfig::areRestrictedUsersAllowed(),
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
            UpdateVisibilityStatus::buildStatusSwitchIsAllowed(),
use Tuleap\Project\UserPermissionsDao;
                        new UGroupManager(),
                        new UserPermissionsDao(),
                        $ugroup_manager,
                        new UserPermissionsDao(),
    private UserRemover $user_remover;
    private UGroupManager $ugroup_manager;
        if (
            ! ForgeConfig::areRestrictedUsersAllowed() ||
            $project->getAccess() !== Project::ACCESS_PRIVATE_WO_RESTRICTED
        ) {

        $project_admins = $project->getAdmins();
        foreach ($project_admins as $project_admin) {
            if ($project_admin->isRestricted()) {
                $this->user_remover->forceRemoveAdminRestrictedUserFromProject(
                    $project,
                    $project_admin,
                );
            }
        }

            <option value="{{ value }}"
                {{# disabled }} disabled {{/ disabled }}
                {{# title }} title="{{ . }}" {{/ title }}
                {{ selected }}
            >{{ label }}</option>
    new \Tuleap\Project\Admin\ProjectVisibilityOptionsForPresenterGenerator(),
    new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
    $project_icons_retriever,
    new \Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker($event_manager),
use Tuleap\Project\Admin\Visibility\UpdateVisibilityStatus;
        $options   = $generator->generateVisibilityOptions(false, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PUBLIC);
        self::assertEqualsCanonicalizing(
        self::assertEquals(Project::ACCESS_PUBLIC, $this->getSelectedAccess($options));
        $options   = $generator->generateVisibilityOptions(true, UpdateVisibilityStatus::buildStatusSwitchIsAllowed(), Project::ACCESS_PRIVATE_WO_RESTRICTED);
        self::assertEqualsCanonicalizing(
        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;
<?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;
                }
            }
        );
    }
}
use ForgeAccess;
use Tuleap\Project\Admin\Visibility\UpdateVisibilityChecker;
use Tuleap\Test\Builders\UserTestBuilder;
            new ProjectIconRetriever(),
            new UpdateVisibilityChecker($this->event_manager),
    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);
    }

use PHPUnit\Framework\MockObject\MockObject;
use Tuleap\Test\Builders\UserTestBuilder;
    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 MockObject&\ArtifactType $tracker_v3;
    private MockObject&UserPermissionsDao $user_permissions_dao;
        $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->ugroup_manager,
            $this->user_permissions_dao,
        $this->tracker_v3 = $this->createMock(\ArtifactType::class);
        $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->event_manager->expects(self::exactly(2))->method('processEvent');
    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);
    }

        $this->project_manager->method('getProject')->with(101)->willReturn($this->project);
        $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');
    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);
    }

        $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]
        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);
            $ugroup_manager,
        $project_manager = $this->createMock(ProjectManager::class);
        $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_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);
        $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(
            self::callback(
        );
        $ugroup_without_restricted = $this->createMock(ProjectUGroup::class);
        $ugroup_without_restricted->method('getMembers')->willReturn([$member]);
        $ugroup_manager->method('getStaticUGroups')->with($project)->willReturn(

    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,
            '',
            '',
            '',
            ''
        );
    }