Merge pull request !2778 from rhodecode-enterprise-ce feature/RCCE-205user-that-owns-closed-PRs-cannot-be-deleted-as-PRs-cannot-be-deleted-or-unclosed

Changes from branch: Feature/RCCE 205user that owns closed PRs cannot be deleted as PRs cannot be deleted or unclosed
This commit is contained in:
Andrii Verbytskyi 2025-08-01 09:30:32 +00:00
commit 58fce87f39
27 changed files with 6033 additions and 386 deletions

View file

@ -134,11 +134,11 @@ PERMISSION_TYPE_SORT = {
def display_user_sort(obj):
"""
Sort function used to sort permissions in .permissions() function of
Repository, RepoGroup, UserGroup. Also it put the default user in front
Repository, RepoGroup, UserGroup. Also it put the default and ghost users in front
of all other resources
"""
if obj.username == User.DEFAULT_USER:
if obj.username in [User.DEFAULT_USER, User.GHOST_USER]:
return "#####"
prefix = PERMISSION_TYPE_SORT.get(obj.permission.split(".")[-1], "")
extra_sort_num = "1" # default
@ -617,6 +617,8 @@ class User(Base, BaseModel):
DEFAULT_USER = "default"
DEFAULT_USER_EMAIL = "anonymous@rhodecode.org"
GHOST_USER = "ghost"
GHOST_USER_EMAIL = "ghost@rhodecode.org"
DEFAULT_GRAVATAR_URL = "https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}"
RECOVERY_CODES_COUNT = 10
@ -1280,13 +1282,21 @@ class User(Base, BaseModel):
qry = qry.filter(User.active == true())
return [x.user_id for x in qry]
@classmethod
def get_ghost_user(cls, cache=False, refresh=False):
return cls._get_system_user(username=cls.GHOST_USER, cache=cache, refresh=refresh)
@classmethod
def get_default_user(cls, cache=False, refresh=False):
user = User.get_by_username(User.DEFAULT_USER, cache=cache)
return cls._get_system_user(username=cls.DEFAULT_USER, cache=cache, refresh=refresh)
@classmethod
def _get_system_user(cls, username, cache=False, refresh=False):
user = cls.get_by_username(username, cache=cache)
if user is None:
raise Exception("FATAL: Missing default account!")
raise Exception("FATAL: Missing %s account!" % username)
if refresh:
# The default user might be based on outdated state which
# The 'system' user might be based on outdated state which
# has been loaded from the cache.
# A call to refresh() ensures that the
# latest state from the database is used.
@ -3037,7 +3047,7 @@ class RepoGroup(Base, BaseModel):
@classmethod
def get_user_personal_repo_group(cls, user_id):
user = User.get(user_id)
if user.username == User.DEFAULT_USER:
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
return None
return cls.query().filter(cls.personal == true()).filter(cls.user == user).order_by(cls.group_id.asc()).first()

View file

@ -664,6 +664,10 @@ class RepoModel(BaseModel):
member_id = int(member_id)
if member_type == "user":
member_name = User.get(member_id).username
if member_name == User.GHOST_USER:
raise ValueError("Modify user permissions for ghost user is not allowed.")
if member_name == User.DEFAULT_USER:
# NOTE(dan): detect if we changed permissions for default user
perm_obj = (

View file

@ -359,7 +359,7 @@ class RepoGroupModel(BaseModel):
elif isinstance(_obj, Repository):
# private repos will not allow to change the default
# permissions using recursive mode
if _obj.private and _user_obj.username == User.DEFAULT_USER:
if _obj.private and _user_obj.username in [User.DEFAULT_USER, User.GHOST_USER]:
log.debug("Skipping private repo %s for user %s", _obj, _user_obj)
return
@ -381,7 +381,7 @@ class RepoGroupModel(BaseModel):
elif isinstance(_obj, Repository):
# private repos will not allow to change the default
# permissions using recursive mode, also there's no revocation fo default user, just update
if _user_obj.username == User.DEFAULT_USER:
if _user_obj.username in [User.DEFAULT_USER, User.GHOST_USER]:
log.debug("Skipping private repo %s for user %s", _obj, _user_obj)
return
RepoModel().revoke_user_permission(repo=_obj, user=_user_obj)
@ -425,7 +425,11 @@ class RepoGroupModel(BaseModel):
if member_type == "user":
member_obj = User.get(member_id)
member_name = member_obj.username
if isinstance(obj, RepoGroup) and obj == repo_group and member_name == User.DEFAULT_USER:
is_repo_group = isinstance(obj, RepoGroup) and obj == repo_group
if is_repo_group and member_name == User.GHOST_USER:
raise ValueError("Modify user permissions for ghost user is not allowed.")
if is_repo_group and member_name == User.DEFAULT_USER:
# NOTE(dan): detect if we changed permissions for default user
perm_obj = (
self.sa.query(UserRepoGroupToPerm)

View file

@ -24,10 +24,12 @@ import logging
import traceback
import datetime
import ipaddress
from typing import List
from sqlalchemy.exc import DatabaseError
from rhodecode import events
from rhodecode.lib import audit_logger
from rhodecode.lib.pyramid_utils import get_current_request
from rhodecode.lib.user_log_filter import user_log_filter
from rhodecode.lib.utils2 import get_current_rhodecode_user, action_logger_generic, AttributeDict, str2bool
@ -56,6 +58,7 @@ from rhodecode.model.db import (
UserEmailMap,
UserIpMap,
UserLog,
PullRequestReviewers,
)
from rhodecode.model.meta import Session
from rhodecode.model.auth_token import AuthTokenModel
@ -128,6 +131,9 @@ class UserModel(BaseModel):
user = user.options(FromCache("sql_cache_short", f"get_user_{name_key}"))
return user.scalar()
def get_by_id(self, id_):
return self.sa.query(User).filter(User.user_id == id_).scalar()
def get_by_email(self, email, cache=False, case_insensitive=False):
return User.get_by_email(email, case_insensitive, cache)
@ -135,7 +141,11 @@ class UserModel(BaseModel):
return User.get_by_auth_token(auth_token, cache)
def get_active_user_count(self, cache=False):
qry = User.query().filter(User.active == true()).filter(User.username != User.DEFAULT_USER)
qry = (
User.query()
.filter(User.active == true())
.filter(User.username.notin_([User.DEFAULT_USER, User.GHOST_USER]))
)
if cache:
qry = qry.options(FromCache("sql_cache_short", "get_active_users"))
return qry.count()
@ -169,10 +179,8 @@ class UserModel(BaseModel):
raise
def update_user(self, user, skip_attrs=None, **kwargs):
from rhodecode.lib.auth import get_crypt_password
user = self._get_user(user)
if user.username == User.DEFAULT_USER:
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
raise DefaultUserException(
"You can't edit this user (`%(username)s`) since it's "
"crucial for entire application" % {"username": user.username}
@ -356,7 +364,7 @@ class UserModel(BaseModel):
edit = True
# we're not allowed to edit default user
if user.username == User.DEFAULT_USER:
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
raise DefaultUserException(
"You can't edit this user (`%(username)s`) since it's "
"crucial for entire application" % {"username": user.username}
@ -459,131 +467,106 @@ class UserModel(BaseModel):
log.error(traceback.format_exc())
raise
def _handle_user_repos(self, username, repositories, handle_user, handle_mode=None):
left_overs = True
def _detach_user_repos(self, username, repositories, new_owner):
for obj in repositories:
obj.user = new_owner
# set description we know why ghost now owns
# additional repositories that were orphaned !
obj.description += " \n::detached repository from deleted user: %s" % (username,)
self.sa.add(obj)
from rhodecode.model.repo import RepoModel
def _detach_user_repo_groups(self, username, repository_groups, new_owner):
for r in repository_groups:
r.user = new_owner
# set description we know why ghost now owns
# additional repositories that were orphaned !
r.group_description += " \n::detached repository group from deleted user: %s" % (username,)
r.personal = False
self.sa.add(r)
if handle_mode == "detach":
for obj in repositories:
obj.user = handle_user
# set description we know why we super admin now owns
# additional repositories that were orphaned !
obj.description += " \n::detached repository from deleted user: %s" % (username,)
self.sa.add(obj)
left_overs = False
elif handle_mode == "delete":
for obj in repositories:
RepoModel().delete(obj, forks="detach")
left_overs = False
def _detach_user_user_groups(self, username, user_groups, new_owner):
for r in user_groups:
for user_user_group_to_perm in r.user_user_group_to_perm:
if user_user_group_to_perm.user.username == username:
user_user_group_to_perm.user = new_owner
r.user = new_owner
# set description we know why ghost now owns
# additional repositories that were orphaned !
r.user_group_description += " \n::detached user group from deleted user: %s" % (username,)
self.sa.add(r)
# if nothing is done we have left overs left
return left_overs
def _detach_user_pull_requests(self, username, pull_requests, new_owner):
for pr in pull_requests:
pr.user_id = new_owner.user_id
# set description we know why ghost now owns
# additional repositories that were orphaned !
pr.description += " \n::detached pull requests from deleted user: %s" % (username,)
self.sa.add(pr)
def _handle_user_repo_groups(self, username, repository_groups, handle_user, handle_mode=None):
left_overs = True
def _detach_user_artifacts(self, username, artifacts, new_owner):
for a in artifacts:
a.upload_user = new_owner
# set description we know why ghost now owns
# additional artifacts that were orphaned !
a.file_description += " \n::detached artifact from deleted user: %s" % (username,)
self.sa.add(a)
from rhodecode.model.repo_group import RepoGroupModel
def _delete_from_reviews(self, user: User, reviewer_pull_requests: List[PullRequestReviewers]):
repos_map = {}
if handle_mode == "detach":
for r in repository_groups:
r.user = handle_user
# set description we know why we super admin now owns
# additional repositories that were orphaned !
r.group_description += " \n::detached repository group from deleted user: %s" % (username,)
r.personal = False
self.sa.add(r)
left_overs = False
elif handle_mode == "delete":
for r in repository_groups:
RepoGroupModel().delete(r)
left_overs = False
for review in reviewer_pull_requests:
repo_id = review.pull_request.target_repo_id
repo_map = repos_map.get(repo_id, {})
# if nothing is done we have left overs left
return left_overs
pull_request_ids_reviewer_role = repo_map.get(audit_logger.PR_IDS_REVIEWER_ROLE, set())
pull_request_ids_observer_role = repo_map.get(audit_logger.PR_IDS_OBSERVER_ROLE, set())
def _handle_user_user_groups(self, username, user_groups, handle_user, handle_mode=None):
left_overs = True
if review.role == PullRequestReviewers.ROLE_REVIEWER:
pull_request_ids_reviewer_role.add(review.pull_request.pull_request_id)
elif review.role == PullRequestReviewers.ROLE_OBSERVER:
pull_request_ids_observer_role.add(review.pull_request.pull_request_id)
from rhodecode.model.user_group import UserGroupModel
repos_map[repo_id] = {
audit_logger.PR_IDS_REVIEWER_ROLE: pull_request_ids_reviewer_role,
audit_logger.PR_IDS_OBSERVER_ROLE: pull_request_ids_observer_role,
"repo_id": repo_id,
"repo_name": review.pull_request.target_repo.repo_name,
}
if handle_mode == "detach":
for r in user_groups:
for user_user_group_to_perm in r.user_user_group_to_perm:
if user_user_group_to_perm.user.username == username:
user_user_group_to_perm.user = handle_user
r.user = handle_user
# set description we know why we super admin now owns
# additional repositories that were orphaned !
r.user_group_description += " \n::detached user group from deleted user: %s" % (username,)
self.sa.add(r)
left_overs = False
elif handle_mode == "delete":
for r in user_groups:
UserGroupModel().delete(r)
left_overs = False
self.sa.delete(review)
# if nothing is done we have left overs left
return left_overs
for repo_id in repos_map:
repo_map = repos_map[repo_id]
def _handle_user_pull_requests(self, username, pull_requests, handle_user, handle_mode=None):
left_overs = True
from rhodecode.model.pull_request import PullRequestModel
if handle_mode == "detach":
for pr in pull_requests:
pr.user_id = handle_user.user_id
# set description we know why we super admin now owns
# additional repositories that were orphaned !
pr.description += " \n::detached pull requests from deleted user: %s" % (username,)
self.sa.add(pr)
left_overs = False
elif handle_mode == "delete":
for pr in pull_requests:
PullRequestModel().delete(pr)
left_overs = False
# if nothing is done we have leftovers left
return left_overs
def _handle_user_artifacts(self, username, artifacts, handle_user, handle_mode=None):
left_overs = True
if handle_mode == "detach":
for a in artifacts:
a.upload_user = handle_user
# set description we know why we super admin now owns
# additional artifacts that were orphaned !
a.file_description += " \n::detached artifact from deleted user: %s" % (username,)
self.sa.add(a)
left_overs = False
elif handle_mode == "delete":
from rhodecode.apps.file_store import utils as store_utils
request = get_current_request()
f_store = store_utils.get_filestore_backend(request.registry.settings)
for a in artifacts:
file_uid = a.file_uid
f_store.delete(file_uid)
self.sa.delete(a)
left_overs = False
# if nothing is done we have left overs left
return left_overs
audit_logger.store_web(
"review.delete",
user=get_current_rhodecode_user(),
action_data={
audit_logger.PR_IDS_OBSERVER_ROLE: list(repo_map.get(audit_logger.PR_IDS_OBSERVER_ROLE, set())),
audit_logger.PR_IDS_REVIEWER_ROLE: list(repo_map.get(audit_logger.PR_IDS_REVIEWER_ROLE, set())),
"prev_user": {
"username": user.username,
"name": user.name,
"email": user.email,
},
},
repo=audit_logger.RepoWrap(
repo_id=repo_map["repo_id"],
repo_name=repo_map["repo_name"],
),
)
def delete(
self,
user,
cur_user=None,
handle_repos=None,
handle_repo_groups=None,
handle_user_groups=None,
handle_pull_requests=None,
handle_artifacts=None,
handle_new_owner=None,
detach_repos=False,
detach_repo_groups=False,
detach_user_groups=False,
detach_pull_requests=False,
detach_artifacts=False,
delete_reviewer=False,
new_owner=None,
):
from rhodecode.lib import hooks_base
@ -593,63 +576,30 @@ class UserModel(BaseModel):
user = self._get_user(user)
try:
if user.username == User.DEFAULT_USER:
if user.username in [User.DEFAULT_USER, User.GHOST_USER]:
raise DefaultUserException("You can't remove this user since it's crucial for entire application")
handle_user = handle_new_owner or self.cls.get_first_super_admin()
log.debug("New detached objects owner %s", handle_user)
left_overs = self._handle_user_repos(user.username, user.repositories, handle_user, handle_repos)
if left_overs and user.repositories:
repos = [x.repo_name for x in user.repositories]
raise UserOwnsReposException(
'user "%(username)s" still owns %(len_repos)s repositories and cannot be '
"removed. Switch owners or remove those repositories:%(list_repos)s"
% {"username": user.username, "len_repos": len(repos), "list_repos": ", ".join(repos)}
)
if delete_reviewer:
self._delete_from_reviews(user, user.reviewer_pull_requests)
left_overs = self._handle_user_repo_groups(
user.username, user.repository_groups, handle_user, handle_repo_groups
)
if left_overs and user.repository_groups:
repo_groups = [x.group_name for x in user.repository_groups]
raise UserOwnsRepoGroupsException(
'user "%(username)s" still owns %(len_repo_groups)s repository groups and cannot be '
"removed. Switch owners or remove those repository groups:%(list_repo_groups)s"
% {
"username": user.username,
"len_repo_groups": len(repo_groups),
"list_repo_groups": ", ".join(repo_groups),
}
)
if detach_repos or detach_repo_groups or detach_user_groups or detach_pull_requests or detach_artifacts:
new_owner = new_owner or self.cls.get_ghost_user()
log.debug("New detached objects owner %s", new_owner)
left_overs = self._handle_user_user_groups(user.username, user.user_groups, handle_user, handle_user_groups)
if left_overs and user.user_groups:
user_groups = [x.users_group_name for x in user.user_groups]
raise UserOwnsUserGroupsException(
'user "%s" still owns %s user groups and cannot be '
"removed. Switch owners or remove those user groups:%s"
% (user.username, len(user_groups), ", ".join(user_groups))
)
if detach_repos:
self._detach_user_repos(user.username, user.repositories, new_owner)
left_overs = self._handle_user_pull_requests(
user.username, user.user_pull_requests, handle_user, handle_pull_requests
)
if left_overs and user.user_pull_requests:
pull_requests = [f"!{x.pull_request_id}" for x in user.user_pull_requests]
raise UserOwnsPullRequestsException(
'user "%s" still owns %s pull requests and cannot be '
"removed. Switch owners or remove those pull requests:%s"
% (user.username, len(pull_requests), ", ".join(pull_requests))
)
if detach_repo_groups:
self._detach_user_repo_groups(user.username, user.repository_groups, new_owner)
left_overs = self._handle_user_artifacts(user.username, user.artifacts, handle_user, handle_artifacts)
if left_overs and user.artifacts:
artifacts = [x.file_uid for x in user.artifacts]
raise UserOwnsArtifactsException(
'user "%s" still owns %s artifacts and cannot be '
"removed. Switch owners or remove those artifacts:%s"
% (user.username, len(artifacts), ", ".join(artifacts))
)
if detach_user_groups:
self._detach_user_user_groups(user.username, user.user_groups, new_owner)
if detach_pull_requests:
self._detach_user_pull_requests(user.username, user.user_pull_requests, new_owner)
if detach_artifacts:
self._detach_user_artifacts(user.username, user.artifacts, new_owner)
user_data = user.get_dict() # fetch user data before expire