This is an automated email from the ASF dual-hosted git repository.

winterhazel pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new 64178ead02d Add missing project cleanups while deleting/moving user 
(#13817)
64178ead02d is described below

commit 64178ead02d18ec565ede449754bfad98799e857
Author: Fabricio Duarte <[email protected]>
AuthorDate: Tue Aug 18 08:39:29 2026 -0300

    Add missing project cleanups while deleting/moving user (#13817)
    
    Co-authored-by: Bernardo De Marco Gonçalves <[email protected]>
---
 .../java/com/cloud/projects/ProjectService.java    |   2 +
 .../api/command/admin/user/MoveUserCmd.java        |   3 +-
 .../apache/cloudstack/region/RegionService.java    |   3 +-
 .../java/com/cloud/projects/ProjectAccountVO.java  |   4 +
 .../com/cloud/projects/ProjectInvitationVO.java    |   4 +
 .../com/cloud/projects/dao/ProjectAccountDao.java  |   7 +-
 .../cloud/projects/dao/ProjectAccountDaoImpl.java  |  31 ++--
 .../cloud/projects/dao/ProjectInvitationDao.java   |   6 +
 .../projects/dao/ProjectInvitationDaoImpl.java     |  35 ++++
 .../META-INF/db/schema-42210to42300-cleanup.sql    |   4 +
 .../apache/cloudstack/ldap/LdapAuthenticator.java  |   8 +-
 .../java/com/cloud/projects/ProjectManager.java    |   3 +
 .../com/cloud/projects/ProjectManagerImpl.java     |  90 ++++++----
 .../main/java/com/cloud/user/AccountManager.java   |   5 +-
 .../java/com/cloud/user/AccountManagerImpl.java    |  52 ++++--
 .../apache/cloudstack/region/RegionManager.java    |   3 +-
 .../cloudstack/region/RegionManagerImpl.java       |   3 +-
 .../cloudstack/region/RegionServiceImpl.java       |   3 +-
 .../com/cloud/projects/MockProjectManagerImpl.java |  12 ++
 .../com/cloud/projects/ProjectManagerImplTest.java | 182 +++++++++++++++++++++
 .../com/cloud/user/AccountManagerImplTest.java     |  12 ++
 21 files changed, 400 insertions(+), 72 deletions(-)

diff --git a/api/src/main/java/com/cloud/projects/ProjectService.java 
b/api/src/main/java/com/cloud/projects/ProjectService.java
index d11e9ae0446..17413de320c 100644
--- a/api/src/main/java/com/cloud/projects/ProjectService.java
+++ b/api/src/main/java/com/cloud/projects/ProjectService.java
@@ -23,6 +23,7 @@ import com.cloud.exception.ResourceAllocationException;
 import com.cloud.exception.ResourceUnavailableException;
 import com.cloud.projects.ProjectAccount.Role;
 import com.cloud.user.Account;
+import com.cloud.user.User;
 
 public interface ProjectService {
     /**
@@ -102,4 +103,5 @@ public interface ProjectService {
 
     boolean addUserToProject(Long projectId, String username, String email, 
Long projectRoleId, Role projectRole) throws ResourceAllocationException;
 
+    void moveProjectAssociationsToUser(User oldUser, User newUser) throws 
ResourceAllocationException;
 }
diff --git 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/MoveUserCmd.java
 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/MoveUserCmd.java
index aab20f108f9..36e1cccca5e 100644
--- 
a/api/src/main/java/org/apache/cloudstack/api/command/admin/user/MoveUserCmd.java
+++ 
b/api/src/main/java/org/apache/cloudstack/api/command/admin/user/MoveUserCmd.java
@@ -18,6 +18,7 @@ package org.apache.cloudstack.api.command.admin.user;
 
 import javax.inject.Inject;
 
+import com.cloud.exception.ResourceAllocationException;
 import org.apache.cloudstack.acl.RoleType;
 import org.apache.cloudstack.api.APICommand;
 import org.apache.cloudstack.api.ApiCommandResourceType;
@@ -112,7 +113,7 @@ public class MoveUserCmd extends BaseCmd {
     }
 
     @Override
-    public void execute() {
+    public void execute() throws ResourceAllocationException {
         Preconditions.checkNotNull(getId(),"I have to have an user to move!");
         
Preconditions.checkState(ObjectUtils.anyNotNull(getAccountId(),getAccountName()),"provide
 either an account name or an account id!");
 
diff --git a/api/src/main/java/org/apache/cloudstack/region/RegionService.java 
b/api/src/main/java/org/apache/cloudstack/region/RegionService.java
index b947b61c8f0..47dbb34dd62 100644
--- a/api/src/main/java/org/apache/cloudstack/region/RegionService.java
+++ b/api/src/main/java/org/apache/cloudstack/region/RegionService.java
@@ -18,6 +18,7 @@ package org.apache.cloudstack.region;
 
 import java.util.List;
 
+import com.cloud.exception.ResourceAllocationException;
 import org.apache.cloudstack.api.command.admin.account.DeleteAccountCmd;
 import org.apache.cloudstack.api.command.admin.account.DisableAccountCmd;
 import org.apache.cloudstack.api.command.admin.account.EnableAccountCmd;
@@ -116,7 +117,7 @@ public interface RegionService {
      * @param moveUserCmd
      * @return true if delete was successful, false otherwise
      */
-    boolean moveUser(MoveUserCmd moveUserCmd);
+    boolean moveUser(MoveUserCmd moveUserCmd) throws 
ResourceAllocationException;
 
     /**
      * update an existing domain
diff --git 
a/engine/schema/src/main/java/com/cloud/projects/ProjectAccountVO.java 
b/engine/schema/src/main/java/com/cloud/projects/ProjectAccountVO.java
index 4710a815f97..0e2f32d5f8b 100644
--- a/engine/schema/src/main/java/com/cloud/projects/ProjectAccountVO.java
+++ b/engine/schema/src/main/java/com/cloud/projects/ProjectAccountVO.java
@@ -110,6 +110,10 @@ public class ProjectAccountVO implements ProjectAccount, 
InternalIdentity {
         return projectAccountId;
     }
 
+    public void setAccountId(long accountId) {
+        this.accountId = accountId;
+    }
+
     public void setProjectRoleId(Long projectRoleId) {
         this.projectRoleId = projectRoleId;
     }
diff --git 
a/engine/schema/src/main/java/com/cloud/projects/ProjectInvitationVO.java 
b/engine/schema/src/main/java/com/cloud/projects/ProjectInvitationVO.java
index 887939311b2..09f19b046c8 100644
--- a/engine/schema/src/main/java/com/cloud/projects/ProjectInvitationVO.java
+++ b/engine/schema/src/main/java/com/cloud/projects/ProjectInvitationVO.java
@@ -102,6 +102,10 @@ public class ProjectInvitationVO implements 
ProjectInvitation {
         return forAccountId;
     }
 
+    public void setForAccountId(Long forAccountId) {
+        this.forAccountId = forAccountId;
+    }
+
     @Override
     public String getToken() {
         return token;
diff --git 
a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDao.java 
b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDao.java
index f4b2f646002..cbc300aa56c 100644
--- a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDao.java
+++ b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDao.java
@@ -20,6 +20,7 @@ import java.util.List;
 
 import com.cloud.projects.ProjectAccount;
 import com.cloud.projects.ProjectAccountVO;
+import com.cloud.user.User;
 import com.cloud.utils.db.GenericDao;
 
 public interface ProjectAccountDao extends GenericDao<ProjectAccountVO, Long> {
@@ -47,9 +48,11 @@ public interface ProjectAccountDao extends 
GenericDao<ProjectAccountVO, Long> {
 
     void removeAccountFromProjects(long accountId);
 
-    void removeUserFromProjects(long userId);
-
     boolean canUserModifyProject(long projectId, long accountId, long userId);
 
     List<ProjectAccountVO> listUsersOrAccountsByRole(long id);
+
+    List<ProjectAccountVO> listBy(Long projectId, Long accountId, Long userId);
+
+    void move(User oldUser, User newUser);
 }
diff --git 
a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDaoImpl.java 
b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDaoImpl.java
index b6eb6d44cea..4de64eef4ca 100644
--- 
a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDaoImpl.java
+++ 
b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectAccountDaoImpl.java
@@ -18,6 +18,7 @@ package com.cloud.projects.dao;
 
 import java.util.List;
 
+import com.cloud.user.User;
 import org.springframework.stereotype.Component;
 
 import com.cloud.projects.ProjectAccount;
@@ -192,17 +193,6 @@ public class ProjectAccountDaoImpl extends 
GenericDaoBase<ProjectAccountVO, Long
         }
     }
 
-    @Override
-    public void removeUserFromProjects(long userId) {
-        SearchCriteria<ProjectAccountVO> sc = AllFieldsSearch.create();
-        sc.setParameters("userId", userId);
-
-        int removedCount = remove(sc);
-        if (removedCount > 0) {
-            logger.debug(String.format("Removed user [%s] from %s 
project(s).", userId, removedCount));
-        }
-    }
-
     @Override
     public boolean canUserModifyProject(long projectId, long accountId, long 
userId) {
         SearchCriteria<ProjectAccountVO> sc = AllFieldsSearch.create();
@@ -222,4 +212,23 @@ public class ProjectAccountDaoImpl extends 
GenericDaoBase<ProjectAccountVO, Long
         sc.setParameters("projectRoleId", id);
         return listBy(sc);
     }
+
+    @Override
+    public List<ProjectAccountVO> listBy(Long projectId, Long accountId, Long 
userId) {
+        SearchCriteria<ProjectAccountVO> sc = AllFieldsSearch.create();
+        sc.setParametersIfNotNull("projectId", projectId);
+        sc.setParametersIfNotNull("userId", userId);
+        sc.setParametersIfNotNull("accountId", accountId);
+        return listBy(sc);
+    }
+
+    @Override
+    public void move(User oldUser, User newUser) {
+        List<ProjectAccountVO> projectAccounts = listBy(null, 
oldUser.getAccountId(), oldUser.getId());
+        for (ProjectAccountVO projectAccount : projectAccounts) {
+            projectAccount.setAccountId(newUser.getAccountId());
+            projectAccount.setUserId(newUser.getId());
+            update(projectAccount.getId(), projectAccount);
+        }
+    }
 }
diff --git 
a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDao.java 
b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDao.java
index 976d53998e2..aba2a881e96 100644
--- 
a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDao.java
+++ 
b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDao.java
@@ -20,6 +20,7 @@ import java.util.List;
 
 import com.cloud.projects.ProjectInvitation.State;
 import com.cloud.projects.ProjectInvitationVO;
+import com.cloud.user.User;
 import com.cloud.utils.db.GenericDao;
 
 public interface ProjectInvitationDao extends GenericDao<ProjectInvitationVO, 
Long> {
@@ -43,4 +44,9 @@ public interface ProjectInvitationDao extends 
GenericDao<ProjectInvitationVO, Lo
 
     List<ProjectInvitationVO> listInvitationsToExpire(long timeOut);
 
+    int removeBy(Long projectId, Long accountId, Long userId);
+
+    List<ProjectInvitationVO> listBy(Long projectId, Long accountId, Long 
userId);
+
+    void move(User oldUser, User newUser);
 }
diff --git 
a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDaoImpl.java
 
b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDaoImpl.java
index d30b1c9f1f1..17e841967fb 100644
--- 
a/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDaoImpl.java
+++ 
b/engine/schema/src/main/java/com/cloud/projects/dao/ProjectInvitationDaoImpl.java
@@ -19,6 +19,7 @@ package com.cloud.projects.dao;
 import java.sql.Date;
 import java.util.List;
 
+import com.cloud.user.User;
 import org.springframework.stereotype.Component;
 
 import com.cloud.projects.ProjectInvitation.State;
@@ -124,6 +125,40 @@ public class ProjectInvitationDaoImpl extends 
GenericDaoBase<ProjectInvitationVO
         return listBy(sc);
     }
 
+    @Override
+    public int removeBy(Long projectId, Long accountId, Long userId) {
+        SearchCriteria<ProjectInvitationVO> sc = 
prepareAllFieldsSearchCriteria(projectId, accountId, userId);
+        return remove(sc);
+    }
+
+    @Override
+    public List<ProjectInvitationVO> listBy(Long projectId, Long accountId, 
Long userId) {
+        SearchCriteria<ProjectInvitationVO> sc = 
prepareAllFieldsSearchCriteria(projectId, accountId, userId);
+        return listBy(sc);
+    }
+
+    @Override
+    public void move(User oldUser, User newUser) {
+        List<ProjectInvitationVO> projectInvitations = listBy(null, 
oldUser.getAccountId(), oldUser.getId());
+        for (ProjectInvitationVO projectInvitation : projectInvitations) {
+            projectInvitation.setForAccountId(newUser.getAccountId());
+            projectInvitation.setForUserId(newUser.getId());
+            update(projectInvitation.getId(), projectInvitation);
+        }
+    }
+
+    private SearchCriteria<ProjectInvitationVO> 
prepareAllFieldsSearchCriteria(Long projectId, Long accountId, Long userId) {
+        SearchCriteria<ProjectInvitationVO> sc = AllFieldsSearch.create();
+
+        sc.setParametersIfNotNull("userId", userId);
+        sc.setParametersIfNotNull("accountId", accountId);
+        if (projectId != null && projectId != -1) {
+            sc.setParameters("projectId", projectId);
+        }
+
+        return sc;
+    }
+
     @Override
     public boolean isActive(long id, long timeout) {
         SearchCriteria<ProjectInvitationVO> sc = InactiveSearch.create();
diff --git 
a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300-cleanup.sql 
b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300-cleanup.sql
index e2b066af780..13b73fe9648 100644
--- 
a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300-cleanup.sql
+++ 
b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300-cleanup.sql
@@ -18,3 +18,7 @@
 --;
 -- Schema upgrade cleanup from 4.22.1.0 to 4.23.0.0
 --;
+
+-- Delete stale project association entries for users that were removed
+DELETE FROM `cloud`.`project_account` WHERE `user_id` IN (SELECT `id` FROM 
`cloud`.`user` WHERE `removed` IS NOT NULL);
+DELETE FROM `cloud`.`project_invitations` WHERE `user_id` IN (SELECT `id` FROM 
`cloud`.`user` WHERE `removed` IS NOT NULL);
diff --git 
a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java
 
b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java
index 09519c5641c..464514f58ce 100644
--- 
a/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java
+++ 
b/plugins/user-authenticators/ldap/src/main/java/org/apache/cloudstack/ldap/LdapAuthenticator.java
@@ -23,6 +23,7 @@ import java.util.UUID;
 
 import javax.inject.Inject;
 
+import com.cloud.exception.ResourceAllocationException;
 import org.apache.cloudstack.acl.RoleType;
 import org.apache.cloudstack.auth.UserAuthenticator;
 import org.apache.commons.collections.CollectionUtils;
@@ -170,7 +171,12 @@ public class LdapAuthenticator extends AdapterBase 
implements UserAuthenticator
                         if (mappedAccount == null || 
mappedAccount.getRemoved() != null) {
                             throw new CloudRuntimeException("Mapped account 
for users does not exist. Please contact your administrator.");
                         }
-                        _accountManager.moveUser(userAccount.getId(), 
userAccount.getDomainId(), mappedAccount);
+                        try {
+                            _accountManager.moveUser(userAccount.getId(), 
userAccount.getDomainId(), mappedAccount);
+                        } catch (ResourceAllocationException e) {
+                            throw new 
CloudRuntimeException(String.format("Failed to move User [%s] to mapped Account 
[%s] due to insufficient Project limits.",
+                                    userAccount.getUsername(), 
mappedAccount.getAccountName()), e);
+                        }
                     }
                     // else { the user hasn't changed in ldap, the ldap group 
stayed the same, hurray, pass, fun thou self a lot of fun }
                 }
diff --git a/server/src/main/java/com/cloud/projects/ProjectManager.java 
b/server/src/main/java/com/cloud/projects/ProjectManager.java
index 5f58205208b..17a443befae 100644
--- a/server/src/main/java/com/cloud/projects/ProjectManager.java
+++ b/server/src/main/java/com/cloud/projects/ProjectManager.java
@@ -19,6 +19,7 @@ package com.cloud.projects;
 import java.util.List;
 
 import com.cloud.user.Account;
+import com.cloud.user.User;
 import org.apache.cloudstack.framework.config.ConfigKey;
 
 public interface ProjectManager extends ProjectService {
@@ -47,6 +48,8 @@ public interface ProjectManager extends ProjectService {
 
     long getInvitationTimeout();
 
+    boolean cleanupProjectsForUser(Project project, User user);
+
     public static final String MESSAGE_CREATE_TUNGSTEN_PROJECT_EVENT = 
"Message.CreateTungstenProject.Event";
     public static final String MESSAGE_DELETE_TUNGSTEN_PROJECT_EVENT = 
"Message.DeleteTungstenProject.Event";
 
diff --git a/server/src/main/java/com/cloud/projects/ProjectManagerImpl.java 
b/server/src/main/java/com/cloud/projects/ProjectManagerImpl.java
index 92af441d06b..9ba5402443e 100644
--- a/server/src/main/java/com/cloud/projects/ProjectManagerImpl.java
+++ b/server/src/main/java/com/cloud/projects/ProjectManagerImpl.java
@@ -617,6 +617,37 @@ public class ProjectManagerImpl extends ManagerBase 
implements ProjectManager, C
         }
     }
 
+    /**
+     * Transfers all project associations and project invitations from one 
user to another.
+     *
+     * @param oldUser the user whose project associations are being transferred
+     * @param newUser the user to whom the project associations are being 
transferred
+     * @throws ResourceAllocationException if there is an issue with 
allocating the required project resources to the new user
+     */
+    @Override
+    public void moveProjectAssociationsToUser(User oldUser, User newUser) 
throws ResourceAllocationException {
+        _projectInvitationDao.move(oldUser, newUser);
+
+        List<ProjectAccountVO> projectAccounts = 
_projectAccountDao.listBy(null, oldUser.getAccountId(), oldUser.getId());
+        if (projectAccounts.isEmpty()) {
+            return;
+        }
+
+        Account oldAccount = _accountDao.findById(oldUser.getAccountId());
+        Account newAccount = _accountDao.findById(newUser.getAccountId());
+        long requiredProjectsAmount = oldAccount.getId() != newAccount.getId()
+                ? projectAccounts.stream().filter(pa -> pa.getAccountRole() == 
ProjectAccount.Role.Admin).count()
+                : 0L;
+
+        try (CheckedReservation projectReservation = new 
CheckedReservation(newAccount, ResourceType.project, null, null, 
requiredProjectsAmount, reservationDao, _resourceLimitMgr)) {
+            _projectAccountDao.move(oldUser, newUser);
+            if (requiredProjectsAmount > 0) {
+                _resourceLimitMgr.incrementResourceCount(newAccount.getId(), 
ResourceType.project, requiredProjectsAmount);
+                _resourceLimitMgr.decrementResourceCount(oldAccount.getId(), 
ResourceType.project, requiredProjectsAmount);
+            }
+        }
+    }
+
     @Override
     public Project findByNameAndDomainId(String name, long domainId) {
         return _projectDao.findByNameAndDomain(name, domainId);
@@ -1033,50 +1064,41 @@ public class ProjectManagerImpl extends ManagerBase 
implements ProjectManager, C
         //verify permissions
         _accountMgr.checkAccess(caller, AccessType.ModifyProject, true, 
_accountMgr.getAccount(project.getProjectAccountId()));
 
-        //Check if the user exists in the project
-        ProjectAccount projectUser =  
_projectAccountDao.findByProjectIdUserId(projectId, user.getAccountId(), 
user.getId());
-        if (projectUser == null) {
-            deletePendingInvite(projectId, user);
+        boolean success = cleanupProjectsForUser(project, user);
+        if (!success) {
             InvalidParameterValueException ex = new 
InvalidParameterValueException("User " + user.getUsername() + " is not assigned 
to the project with specified id");
-            // Use the projectVO object and not the projectAccount object to 
inject the projectId.
             ex.addProxyObject(project.getUuid(), "projectId");
             throw ex;
         }
-        return deleteUserFromProject(projectId, user);
+        return true;
     }
 
-    private void deletePendingInvite(Long projectId, User user) {
-        ProjectInvitation invite = 
_projectInvitationDao.findByUserIdProjectId(user.getId(), user.getAccountId(),  
projectId);
-        if (invite != null) {
-            boolean success = _projectInvitationDao.remove(invite.getId());
-            if (success){
-                logger.info("Successfully deleted invite pending for the user 
: {}", user);
-            } else {
-                logger.info("Failed to delete project invite for user: {}", 
user);
-            }
-        }
-    }
+    /**
+     * Cleans up project associations and invitations for a specified user in 
a given project.
+     *
+     * @param project the project from which the user is being cleaned up; if 
null, cleanup applies to all projects associated with the user
+     * @param user the user whose project associations and invitations are 
being cleaned up
+     * @return true if any project accounts associated with the user were 
removed, false otherwise
+     */
+    @Override
+    public boolean cleanupProjectsForUser(Project project, User user) {
+        return Transaction.execute((TransactionCallback<Boolean>) status -> {
+            Long projectId = project != null ? project.getId() : null;
+            long userId = user.getId();
+            long accountId = user.getAccountId();
 
-    @DB
-    private boolean deleteUserFromProject(Long projectId, User user) {
-        return Transaction.execute(new TransactionCallback<Boolean>() {
-            @Override
-            public Boolean doInTransaction(TransactionStatus status) {
-                boolean success = true;
-                ProjectAccountVO projectAccount = 
_projectAccountDao.findByProjectIdUserId(projectId, user.getAccountId(), 
user.getId());
-                success = _projectAccountDao.remove(projectAccount.getId());
+            _projectInvitationDao.removeBy(projectId, accountId, userId);
+
+            List<ProjectAccountVO> projectAccounts = 
_projectAccountDao.listBy(projectId, accountId, userId);
+            for (ProjectAccountVO projectAccount : projectAccounts) {
+                _projectAccountDao.remove(projectAccount.getId());
                 if (projectAccount.getAccountRole() == Role.Admin) {
-                    
_resourceLimitMgr.decrementResourceCount(user.getAccountId(), 
ResourceType.project);
+                    _resourceLimitMgr.decrementResourceCount(accountId, 
ResourceType.project);
                 }
-                if (success) {
-                    logger.debug("Removed user {} from project. Removing any 
invite sent to the user", user);
-                    ProjectInvitation invite = 
_projectInvitationDao.findByUserIdProjectId(user.getId(), user.getAccountId(),  
projectId);
-                    if (invite != null) {
-                        success = success && 
_projectInvitationDao.remove(invite.getId());
-                    }
-                }
-                return success;
+                logger.debug("Removed user [{}] from project [{}].", user, 
projectAccount.getProjectId());
             }
+
+            return !projectAccounts.isEmpty();
         });
     }
 
diff --git a/server/src/main/java/com/cloud/user/AccountManager.java 
b/server/src/main/java/com/cloud/user/AccountManager.java
index eca1a571dd8..e3840e29725 100644
--- a/server/src/main/java/com/cloud/user/AccountManager.java
+++ b/server/src/main/java/com/cloud/user/AccountManager.java
@@ -20,6 +20,7 @@ import java.net.InetAddress;
 import java.util.List;
 import java.util.Map;
 
+import com.cloud.exception.ResourceAllocationException;
 import org.apache.cloudstack.acl.ControlledEntity;
 import org.apache.cloudstack.acl.apikeypair.ApiKeyPair;
 import org.apache.cloudstack.api.command.admin.account.UpdateAccountCmd;
@@ -148,7 +149,7 @@ public interface AccountManager extends AccountService, 
Configurable {
      * moves a user to another account within the same domain
      * @return true if the user was successfully moved
      */
-    boolean moveUser(MoveUserCmd moveUserCmd);
+    boolean moveUser(MoveUserCmd moveUserCmd) throws 
ResourceAllocationException;
 
     @Override
     UserAccount updateUser(UpdateUserCmd cmd);
@@ -190,7 +191,7 @@ public interface AccountManager extends AccountService, 
Configurable {
     ConfigKey<Boolean> UseSecretKeyInResponse = new 
ConfigKey<Boolean>("Advanced", Boolean.class, "use.secret.key.in.response", 
"false",
             "This parameter allows the users to enable or disable of showing 
secret key as a part of response for various APIs. By default it is set to 
false.", true);
 
-    boolean moveUser(long id, Long domainId, Account newAccount);
+    boolean moveUser(long id, Long domainId, Account newAccount) throws 
ResourceAllocationException;
 
     UserTwoFactorAuthenticator getUserTwoFactorAuthenticator(final Long 
domainId, final Long userAccountId);
 
diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java 
b/server/src/main/java/com/cloud/user/AccountManagerImpl.java
index db9c1d1dafd..e4d835f74b1 100644
--- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java
+++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java
@@ -45,10 +45,13 @@ import javax.crypto.spec.SecretKeySpec;
 import javax.inject.Inject;
 import javax.naming.ConfigurationException;
 
+import com.cloud.exception.ResourceAllocationException;
+import com.cloud.projects.dao.ProjectInvitationDao;
 import com.cloud.user.dao.AccountDao;
 import com.cloud.user.dao.SSHKeyPairDao;
 import com.cloud.user.dao.UserAccountDao;
 import com.cloud.user.dao.UserDao;
+import com.cloud.utils.db.TransactionCallbackWithException;
 import org.apache.cloudstack.acl.APIChecker;
 import org.apache.cloudstack.acl.ApiKeyPairManagerImpl;
 import org.apache.cloudstack.acl.ApiKeyPairPermissionVO;
@@ -315,6 +318,8 @@ public class AccountManagerImpl extends ManagerBase 
implements AccountManager, M
     @Inject
     private ProjectAccountDao _projectAccountDao;
     @Inject
+    private ProjectInvitationDao projectInvitationDao;
+    @Inject
     private IPAddressDao _ipAddressDao;
     @Inject
     private HostDao hostDao;
@@ -2521,14 +2526,29 @@ public class AccountManagerImpl extends ManagerBase 
implements AccountManager, M
         checkAccountAndAccess(user, account);
         verifyCallerPrivilegeForUserOrAccountOperations(user);
 
-        removeUserApiKeys(id);
+        return deleteAndCleanupUser(user);
+    }
+
+    /**
+     * Removes the specified user and performs cleanup operations associated 
with the user.
+     *
+     * @param user the user to be deleted and cleaned up
+     * @return true if the user was successfully marked as removed, false 
otherwise
+     */
+    protected boolean deleteAndCleanupUser(User user) {
+        return Transaction.execute((TransactionCallback<Boolean>) status -> {
+            long userId = user.getId();
+
+            removeUserApiKeys(userId);
+            _projectMgr.cleanupProjectsForUser(null, user);
 
-        return _userDao.remove(id);
+            return _userDao.remove(userId);
+        });
     }
 
     @Override
     @ActionEvent(eventType = EventTypes.EVENT_USER_MOVE, eventDescription = 
"moving User to a new account")
-    public boolean moveUser(MoveUserCmd cmd) {
+    public boolean moveUser(MoveUserCmd cmd) throws 
ResourceAllocationException {
         final Long id = cmd.getId();
         UserVO user = getValidUserVO(id);
         Account oldAccount = _accountDao.findById(user.getAccountId());
@@ -2542,7 +2562,7 @@ public class AccountManagerImpl extends ManagerBase 
implements AccountManager, M
     }
 
     @Override
-    public boolean moveUser(long id, Long domainId, Account newAccount) {
+    public boolean moveUser(long id, Long domainId, Account newAccount) throws 
ResourceAllocationException {
         UserVO user = getValidUserVO(id);
         Account oldAccount = _accountDao.findById(user.getAccountId());
         checkAccountAndAccess(user, oldAccount);
@@ -2550,24 +2570,22 @@ public class AccountManagerImpl extends ManagerBase 
implements AccountManager, M
         return moveUser(user, newAccount.getId());
     }
 
-    private boolean moveUser(UserVO user, long newAccountId) {
+    private boolean moveUser(UserVO user, long newAccountId) throws 
ResourceAllocationException {
         if (newAccountId == user.getAccountId()) {
             // could do a not silent fail but the objective of the user is 
reached
             return true; // no need to create a new user object for this user
         }
 
-        return Transaction.execute(new TransactionCallback<>() {
-            @Override
-            public Boolean doInTransaction(TransactionStatus status) {
-                UserVO newUser = new UserVO(user);
-                user.setExternalEntity(user.getUuid());
-                user.setUuid(UUID.randomUUID().toString());
-                _userDao.update(user.getId(), user);
-                newUser.setAccountId(newAccountId);
-                boolean success = _userDao.remove(user.getId());
-                UserVO persisted = _userDao.persist(newUser);
-                return success && 
persisted.getUuid().equals(user.getExternalEntity());
-            }
+        return Transaction.execute((TransactionCallbackWithException<Boolean, 
ResourceAllocationException>) status -> {
+            UserVO newUser = new UserVO(user);
+            user.setExternalEntity(user.getUuid());
+            user.setUuid(UUID.randomUUID().toString());
+            _userDao.update(user.getId(), user);
+            newUser.setAccountId(newAccountId);
+            UserVO persisted = _userDao.persist(newUser);
+            _projectMgr.moveProjectAssociationsToUser(user, persisted);
+            boolean success = _userDao.remove(user.getId());
+            return success && 
persisted.getUuid().equals(user.getExternalEntity());
         });
     }
 
diff --git 
a/server/src/main/java/org/apache/cloudstack/region/RegionManager.java 
b/server/src/main/java/org/apache/cloudstack/region/RegionManager.java
index fedd66d9440..4e7eaad9f2c 100644
--- a/server/src/main/java/org/apache/cloudstack/region/RegionManager.java
+++ b/server/src/main/java/org/apache/cloudstack/region/RegionManager.java
@@ -18,6 +18,7 @@ package org.apache.cloudstack.region;
 
 import java.util.List;
 
+import com.cloud.exception.ResourceAllocationException;
 import org.apache.cloudstack.api.command.admin.account.UpdateAccountCmd;
 import org.apache.cloudstack.api.command.admin.domain.UpdateDomainCmd;
 import org.apache.cloudstack.api.command.admin.user.DeleteUserCmd;
@@ -128,7 +129,7 @@ public interface RegionManager {
      * @param moveUserCmd
      * @return
      */
-    boolean moveUser(MoveUserCmd moveUserCmd);
+    boolean moveUser(MoveUserCmd moveUserCmd) throws 
ResourceAllocationException;
 
     /**
      * update an existing domain
diff --git 
a/server/src/main/java/org/apache/cloudstack/region/RegionManagerImpl.java 
b/server/src/main/java/org/apache/cloudstack/region/RegionManagerImpl.java
index 3085f655943..a49ba4085cb 100644
--- a/server/src/main/java/org/apache/cloudstack/region/RegionManagerImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/region/RegionManagerImpl.java
@@ -24,6 +24,7 @@ import java.util.Properties;
 import javax.inject.Inject;
 import javax.naming.ConfigurationException;
 
+import com.cloud.exception.ResourceAllocationException;
 import org.apache.cloudstack.api.command.admin.user.MoveUserCmd;
 import org.springframework.stereotype.Component;
 
@@ -227,7 +228,7 @@ public class RegionManagerImpl extends ManagerBase 
implements RegionManager, Man
      * {@inheritDoc}
      */
     @Override
-    public boolean moveUser(MoveUserCmd cmd) {
+    public boolean moveUser(MoveUserCmd cmd) throws 
ResourceAllocationException {
         return _accountMgr.moveUser(cmd);
     }
 
diff --git 
a/server/src/main/java/org/apache/cloudstack/region/RegionServiceImpl.java 
b/server/src/main/java/org/apache/cloudstack/region/RegionServiceImpl.java
index 982395637e3..f0db79f7cee 100644
--- a/server/src/main/java/org/apache/cloudstack/region/RegionServiceImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/region/RegionServiceImpl.java
@@ -22,6 +22,7 @@ import java.util.Map;
 import javax.inject.Inject;
 import javax.naming.ConfigurationException;
 
+import com.cloud.exception.ResourceAllocationException;
 import org.springframework.stereotype.Component;
 
 import org.apache.cloudstack.api.command.admin.account.DeleteAccountCmd;
@@ -154,7 +155,7 @@ public class RegionServiceImpl extends ManagerBase 
implements RegionService, Man
      * {@inheritDoc}
      */
     @Override
-    public boolean moveUser(MoveUserCmd cmd) {
+    public boolean moveUser(MoveUserCmd cmd) throws 
ResourceAllocationException {
         return _regionMgr.moveUser(cmd);
     }
 
diff --git 
a/server/src/test/java/com/cloud/projects/MockProjectManagerImpl.java 
b/server/src/test/java/com/cloud/projects/MockProjectManagerImpl.java
index 0abcf9591d4..36a2e78d461 100644
--- a/server/src/test/java/com/cloud/projects/MockProjectManagerImpl.java
+++ b/server/src/test/java/com/cloud/projects/MockProjectManagerImpl.java
@@ -21,6 +21,7 @@ import com.cloud.exception.ResourceAllocationException;
 import com.cloud.exception.ResourceUnavailableException;
 import com.cloud.projects.ProjectAccount.Role;
 import com.cloud.user.Account;
+import com.cloud.user.User;
 import com.cloud.utils.component.ManagerBase;
 
 import javax.naming.ConfigurationException;
@@ -214,6 +215,17 @@ public class MockProjectManagerImpl extends ManagerBase 
implements ProjectManage
         return 0;
     }
 
+    @Override
+    public boolean cleanupProjectsForUser(Project project, User user) {
+        // TODO Auto-generated method stub
+        return false;
+    }
+
+    @Override
+    public void moveProjectAssociationsToUser(User oldUser, User newUser) 
throws ResourceAllocationException {
+        // TODO Auto-generated method stub
+    }
+
     @Override
     public Project findByProjectAccountIdIncludingRemoved(long 
projectAccountId) {
         return null;
diff --git 
a/server/src/test/java/com/cloud/projects/ProjectManagerImplTest.java 
b/server/src/test/java/com/cloud/projects/ProjectManagerImplTest.java
index b9b568facc2..ca6c2193fbd 100644
--- a/server/src/test/java/com/cloud/projects/ProjectManagerImplTest.java
+++ b/server/src/test/java/com/cloud/projects/ProjectManagerImplTest.java
@@ -17,9 +17,11 @@
 package com.cloud.projects;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 import org.apache.cloudstack.acl.ControlledEntity;
+import org.apache.cloudstack.reservation.dao.ReservationDao;
 import org.apache.cloudstack.webhook.WebhookHelper;
 import org.apache.commons.collections.CollectionUtils;
 import org.junit.Assert;
@@ -28,6 +30,7 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.MockedConstruction;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 import org.mockito.Spy;
@@ -35,7 +38,17 @@ import org.mockito.junit.MockitoJUnitRunner;
 import org.mockito.stubbing.Answer;
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 
+import com.cloud.configuration.Resource.ResourceType;
+import com.cloud.exception.ResourceAllocationException;
+import com.cloud.projects.ProjectAccount.Role;
+import com.cloud.projects.dao.ProjectAccountDao;
 import com.cloud.projects.dao.ProjectDao;
+import com.cloud.projects.dao.ProjectInvitationDao;
+import com.cloud.resourcelimit.CheckedReservation;
+import com.cloud.user.AccountVO;
+import com.cloud.user.ResourceLimitService;
+import com.cloud.user.User;
+import com.cloud.user.dao.AccountDao;
 import com.cloud.utils.component.ComponentContext;
 
 
@@ -49,6 +62,21 @@ public class ProjectManagerImplTest {
     @Mock
     ProjectDao projectDao;
 
+    @Mock
+    ProjectInvitationDao projectInvitationDao;
+
+    @Mock
+    ProjectAccountDao projectAccountDao;
+
+    @Mock
+    AccountDao accountDao;
+
+    @Mock
+    ResourceLimitService resourceLimitMgr;
+
+    @Mock
+    ReservationDao reservationDao;
+
     List<ProjectVO> updateProjects;
 
     @Before
@@ -128,4 +156,158 @@ public class ProjectManagerImplTest {
             Assert.assertTrue(CollectionUtils.isEmpty(result));
         }
     }
+
+    @Test
+    public void cleanupProjectsForUserTestNoAssociationsReturnsFalse() {
+        Project project = Mockito.mock(Project.class);
+        Mockito.when(project.getId()).thenReturn(100L);
+        User user = mockUser(1L, 10L);
+        Mockito.when(projectAccountDao.listBy(Mockito.anyLong(), 
Mockito.anyLong(), Mockito.anyLong()))
+                .thenReturn(Collections.emptyList());
+
+        boolean result = projectManager.cleanupProjectsForUser(project, user);
+
+        Assert.assertFalse(result);
+        Mockito.verify(projectInvitationDao).removeBy(100L, 10L, 1L);
+        Mockito.verify(projectAccountDao, 
Mockito.never()).remove(Mockito.anyLong());
+        Mockito.verify(resourceLimitMgr, 
Mockito.never()).decrementResourceCount(Mockito.anyLong(), 
Mockito.any(ResourceType.class));
+    }
+
+    @Test
+    public void cleanupProjectsForUserTestRemovesAdminAndRegularAssociations() 
{
+        Project project = Mockito.mock(Project.class);
+        Mockito.when(project.getId()).thenReturn(100L);
+        User user = mockUser(1L, 10L);
+        ProjectAccountVO admin = mockProjectAccount(1L, Role.Admin);
+        ProjectAccountVO regular = mockProjectAccount(2L, Role.Regular);
+        Mockito.when(projectAccountDao.listBy(Mockito.anyLong(), 
Mockito.anyLong(), Mockito.anyLong()))
+                .thenReturn(List.of(admin, regular));
+
+        boolean result = projectManager.cleanupProjectsForUser(project, user);
+
+        Assert.assertTrue(result);
+        Mockito.verify(projectInvitationDao).removeBy(100L, 10L, 1L);
+        Mockito.verify(projectAccountDao).remove(1L);
+        Mockito.verify(projectAccountDao).remove(2L);
+        Mockito.verify(resourceLimitMgr).decrementResourceCount(10L, 
ResourceType.project);
+    }
+
+    @Test
+    public void cleanupProjectsForUserTestNullProject() {
+        User user = mockUser(1L, 10L);
+        ProjectAccountVO admin = mockProjectAccount(1L, Role.Admin);
+        Mockito.when(projectAccountDao.listBy(Mockito.isNull(), 
Mockito.eq(10L), Mockito.eq(1L)))
+                .thenReturn(List.of(admin));
+
+        boolean result = projectManager.cleanupProjectsForUser(null, user);
+
+        Assert.assertTrue(result);
+        Mockito.verify(projectInvitationDao).removeBy(Mockito.isNull(), 
Mockito.eq(10L), Mockito.eq(1L));
+        Mockito.verify(projectAccountDao).remove(1L);
+        Mockito.verify(resourceLimitMgr).decrementResourceCount(10L, 
ResourceType.project);
+    }
+
+    @Test
+    public void moveProjectAssociationsToUserTestNoProjectAccounts() throws 
ResourceAllocationException {
+        User oldUser = mockUser(1L, 10L);
+        User newUser = mockUser(2L, 20L);
+        Mockito.when(projectAccountDao.listBy(Mockito.isNull(), 
Mockito.eq(10L), Mockito.eq(1L)))
+                .thenReturn(Collections.emptyList());
+
+        projectManager.moveProjectAssociationsToUser(oldUser, newUser);
+
+        Mockito.verify(projectInvitationDao).move(oldUser, newUser);
+        Mockito.verify(projectAccountDao, Mockito.never()).move(Mockito.any(), 
Mockito.any());
+        Mockito.verifyNoInteractions(accountDao);
+        Mockito.verifyNoInteractions(resourceLimitMgr);
+    }
+
+    @Test
+    public void moveProjectAssociationsToUserTestSameAccount() throws 
ResourceAllocationException {
+        User oldUser = mockUser(1L, 10L);
+        User newUser = mockUser(2L, 10L);
+        ProjectAccountVO regular = mockProjectAccount(1L, Role.Regular);
+        Mockito.when(projectAccountDao.listBy(Mockito.isNull(), 
Mockito.eq(10L), Mockito.eq(1L)))
+                .thenReturn(List.of(regular));
+        AccountVO oldAccount = mockAccount(10L);
+        AccountVO newAccount = mockAccount(10L);
+        
Mockito.when(accountDao.findById(10L)).thenReturn(oldAccount).thenReturn(newAccount);
+
+        try (MockedConstruction<CheckedReservation> ignored = 
Mockito.mockConstruction(CheckedReservation.class)) {
+            projectManager.moveProjectAssociationsToUser(oldUser, newUser);
+        }
+
+        Mockito.verify(projectInvitationDao).move(oldUser, newUser);
+        Mockito.verify(projectAccountDao).move(oldUser, newUser);
+        Mockito.verify(resourceLimitMgr, Mockito.never())
+                .incrementResourceCount(Mockito.anyLong(), 
Mockito.any(ResourceType.class), Mockito.anyLong());
+        Mockito.verify(resourceLimitMgr, Mockito.never())
+                .decrementResourceCount(Mockito.anyLong(), 
Mockito.any(ResourceType.class), Mockito.anyLong());
+    }
+
+    @Test
+    public void 
moveProjectAssociationsToUserTestDifferentAccountsWithAdminRole() throws 
ResourceAllocationException {
+        User oldUser = mockUser(1L, 10L);
+        User newUser = mockUser(2L, 20L);
+        ProjectAccountVO admin = mockProjectAccount(1L, Role.Admin);
+        Mockito.when(projectAccountDao.listBy(Mockito.isNull(), 
Mockito.eq(10L), Mockito.eq(1L)))
+                .thenReturn(List.of(admin));
+        AccountVO oldAccount = mockAccount(10L);
+        AccountVO newAccount = mockAccount(20L);
+        Mockito.when(accountDao.findById(10L)).thenReturn(oldAccount);
+        Mockito.when(accountDao.findById(20L)).thenReturn(newAccount);
+
+        try (MockedConstruction<CheckedReservation> ignored = 
Mockito.mockConstruction(CheckedReservation.class)) {
+            projectManager.moveProjectAssociationsToUser(oldUser, newUser);
+        }
+
+        Mockito.verify(projectInvitationDao).move(oldUser, newUser);
+        Mockito.verify(projectAccountDao).move(oldUser, newUser);
+        Mockito.verify(resourceLimitMgr).incrementResourceCount(20L, 
ResourceType.project, 1L);
+        Mockito.verify(resourceLimitMgr).decrementResourceCount(10L, 
ResourceType.project, 1L);
+    }
+
+    @Test
+    public void 
moveProjectAssociationsToUserTestDifferentAccountsWithoutAdminRole() throws 
ResourceAllocationException {
+        User oldUser = mockUser(1L, 10L);
+        User newUser = mockUser(2L, 20L);
+        ProjectAccountVO regular = mockProjectAccount(1L, Role.Regular);
+        Mockito.when(projectAccountDao.listBy(Mockito.isNull(), 
Mockito.eq(10L), Mockito.eq(1L)))
+                .thenReturn(List.of(regular));
+        AccountVO oldAccount = mockAccount(10L);
+        AccountVO newAccount = mockAccount(20L);
+        Mockito.when(accountDao.findById(10L)).thenReturn(oldAccount);
+        Mockito.when(accountDao.findById(20L)).thenReturn(newAccount);
+
+        try (MockedConstruction<CheckedReservation> ignored = 
Mockito.mockConstruction(CheckedReservation.class)) {
+            projectManager.moveProjectAssociationsToUser(oldUser, newUser);
+        }
+
+        Mockito.verify(projectInvitationDao).move(oldUser, newUser);
+        Mockito.verify(projectAccountDao).move(oldUser, newUser);
+        Mockito.verify(resourceLimitMgr, Mockito.never())
+                .incrementResourceCount(Mockito.anyLong(), 
Mockito.any(ResourceType.class), Mockito.anyLong());
+        Mockito.verify(resourceLimitMgr, Mockito.never())
+                .decrementResourceCount(Mockito.anyLong(), 
Mockito.any(ResourceType.class), Mockito.anyLong());
+    }
+
+    private User mockUser(long id, long accountId) {
+        User user = Mockito.mock(User.class);
+        Mockito.when(user.getId()).thenReturn(id);
+        Mockito.when(user.getAccountId()).thenReturn(accountId);
+        return user;
+    }
+
+    private AccountVO mockAccount(long id) {
+        AccountVO account = Mockito.mock(AccountVO.class);
+        Mockito.when(account.getId()).thenReturn(id);
+        return account;
+    }
+
+    private ProjectAccountVO mockProjectAccount(long id, Role role) {
+        ProjectAccountVO projectAccount = Mockito.mock(ProjectAccountVO.class);
+        Mockito.when(projectAccount.getId()).thenReturn(id);
+        Mockito.when(projectAccount.getAccountRole()).thenReturn(role);
+        return projectAccount;
+    }
 }
diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java 
b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
index 61cdde697dd..379ae76d119 100644
--- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
+++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java
@@ -2131,4 +2131,16 @@ public class AccountManagerImplTest extends 
AccountManagentImplTestBase {
         );
         Assert.assertNotNull(userResultVO);
     }
+
+    @Test
+    public void deleteAndCleanupUserTestUserCleanup() {
+        long userId = userVoMock.getId();
+        Mockito.doNothing().when(accountManagerImpl).removeUserApiKeys(userId);
+        Mockito.doReturn(true).when(_projectMgr).cleanupProjectsForUser(null, 
userVoMock);
+
+        accountManagerImpl.deleteAndCleanupUser(userVoMock);
+
+        Mockito.verify(accountManagerImpl).removeUserApiKeys(userId);
+        Mockito.verify(_projectMgr).cleanupProjectsForUser(null, userVoMock);
+    }
 }

Reply via email to