Merge branch 'main' into feature/setting-to-pre-populate-branch-in-pr

This commit is contained in:
Andrii V 2025-08-08 19:24:20 +02:00
commit c0727ac73d
33 changed files with 816 additions and 357 deletions

View file

@ -0,0 +1,37 @@
|RCE| 5.7.0 |RNS|
-----------------
Release Date
^^^^^^^^^^^^
- 2025-08-15
New Features
^^^^^^^^^^^^
- squash: The pull request UI now includes a dropdown menu, allowing users to select "Squash" or "Close" when merging pull requests in Git and Mercurial repositories, instead of only having the "Merge" option. This feature is exclusive to the Enterprise Edition (EE) and must be enabled by an administrator.
- pull request: Introduced a new checkbox in the pull request UI, allowing users to select the option to close the branch after merging. This feature is exclusive to the Enterprise Edition (EE).
- user: It is now possible to remove users from the system. When a user is deleted, all of their assets will automatically be reassigned to a new system user, Ghost User.
- active directory: Added the ability to sync active and inactive users from LDAP/Active Directory. This feature must be enabled in the LDAP authentication plugin by an administrator, is available only for Active Directory, and is exclusive to the Enterprise Edition (EE).
General
^^^^^^^
Security
^^^^^^^^
Performance
^^^^^^^^^^^
Fixes
^^^^^
- ssh: Fixed an issue where the app.service_api.token field could be left empty in the configuration. The application will now automatically populate this field if it is missing.
- timezone: Fixed an issue in the age function where comparing a timezone-aware and a naive datetime object could cause an exception.
Upgrade notes
^^^^^^^^^^^^^

View file

@ -19,6 +19,8 @@
import mock
import pytest
from rhodecode.model.db import Repository
from rhodecode.model.meta import Session
from rhodecode.model.repo import RepoModel
from rhodecode.api.tests.utils import build_data, api_call, assert_error, assert_ok, crash
@ -27,6 +29,9 @@ from rhodecode.api.tests.utils import build_data, api_call, assert_error, assert
class TestApiDeleteRepo(object):
def test_api_delete_repo(self, backend):
repo = backend.create_repo()
repo.private = True
Session().commit()
repo_name = repo.repo_name
id_, params = build_data(
self.apikey,
@ -38,6 +43,46 @@ class TestApiDeleteRepo(object):
expected = {"msg": "Deleted repository `%s`" % (repo_name,), "success": True}
assert_ok(id_, expected, given=response.body)
@pytest.mark.parametrize(
"fork_action, expected_fork_msg",
[
("detach", "Detached 1 forks"),
("delete", "Deleted 1 forks"),
],
)
def test_api_delete_repo_with_forks(self, backend, fork_action, expected_fork_msg):
repo = backend.create_repo()
repo.private = True
fork = backend.create_fork()
fork_name = fork.repo_name
Session().commit()
assert fork.parent is not None
repo_name = repo.repo_name
id_, params = build_data(
self.apikey,
"delete_repo",
repoid=repo.repo_name,
forks=fork_action,
)
response = api_call(self.app, params)
expected = {
"msg": "Deleted repository `%s` %s; NOTE: 'forks' option will be deprecated."
% (repo_name, expected_fork_msg),
"success": True,
}
assert_ok(id_, expected, given=response.body)
self._validate_fork_db_state(fork_action, fork_name)
def _validate_fork_db_state(self, fork_action, fork_name):
fork = Repository.get_by_repo_name(repo_name=fork_name)
if fork_action == "detach":
assert fork.parent is None
elif fork_action == "delete":
assert fork is None
def test_api_delete_repo_by_non_admin(self, backend, user_regular):
repo = backend.create_repo(cur_user=user_regular.username)
repo_name = repo.repo_name

View file

@ -48,7 +48,7 @@ def assert_ok(id_, expected, given):
expected = jsonify({"id": id_, "error": None, "result": expected})
assert expected == given
assert expected == given, "Expected: %s, Given: %s" % (expected, given)
def assert_error(id_, expected, given):

View file

@ -50,7 +50,7 @@ from rhodecode.model.comment import CommentsModel
from rhodecode.model.db import Session, ChangesetStatus, RepositoryField, Repository, RepoGroup, ChangesetComment
from rhodecode.model.permission import PermissionModel
from rhodecode.model.pull_request import PullRequestModel
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.scm import ScmModel, RepoList
from rhodecode.model.settings import SettingsModel, VcsSettingsModel
from rhodecode.model import validation_schema
@ -1360,17 +1360,18 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")):
validate_repo_permissions(apiuser, repoid, repo, _perms)
try:
handle_forks = Optional.extract(forks)
str_fork_action = Optional.extract(forks)
_forks_msg = ""
_forks = [f for f in repo.forks]
if handle_forks == "detach":
if str_fork_action == "detach":
_forks_msg = " " + f"Detached {len(_forks)} forks"
elif handle_forks == "delete":
elif str_fork_action == "delete":
_forks_msg = " " + f"Deleted {len(_forks)} forks"
elif _forks:
raise JSONRPCError(f"Cannot delete `{repo.repo_name}` it still contains attached forks")
old_data = repo.get_api_data()
RepoModel().delete(repo, forks=forks)
RepoModel().delete(repo, forks_action=ForksAction(str_fork_action))
repo = audit_logger.RepoWrap(repo_id=None, repo_name=repo.repo_name)
@ -1378,7 +1379,12 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")):
ScmModel().mark_for_invalidation(repo_name, delete=True)
Session().commit()
return {"msg": f"Deleted repository `{repo_name}`{_forks_msg}", "success": True}
res_message = {"msg": f"Deleted repository `{repo_name}`{_forks_msg}", "success": True}
if str_fork_action:
deprecation_warning = "; NOTE: 'forks' option will be deprecated."
res_message["msg"] += deprecation_warning
return res_message
except Exception:
log.exception("Exception occurred while trying to delete repo")
raise JSONRPCError(f"failed to delete repository `{repo_name}`")

View file

@ -587,8 +587,14 @@ class AdminSettingsView(BaseAppView):
c.active = "search"
c.searcher = searcher_from_config(self.request.registry.settings)
c.statistics = c.searcher.statistics(self.request.translate)
c.error = None
c.statistics = None
try:
c.statistics = c.searcher.statistics(self.request.translate)
except Exception as e:
log.exception("Exception during search statistics retrieval")
c.error = e
return self._get_template_context(c)
@LoginRequired()

View file

@ -21,7 +21,8 @@ from unittest.mock import patch, MagicMock
from rhodecode.apps.repository.views.repo_settings_advanced import RepoSettingsAdvancedView
from rhodecode.lib.str_utils import safe_str
from rhodecode.model.db import Repository
from rhodecode.model.repo import RepoModel
from rhodecode.model.meta import Session
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.tests import HG_REPO, GIT_REPO, assert_session_flash, no_newline_id_generator
from rhodecode.tests.fixtures.rc_fixture import Fixture
from rhodecode.tests.utils import repo_on_filesystem
@ -61,7 +62,7 @@ class TestAdminRepoSettingsAdvanced(object):
response.mustcontain(opt)
fixture.destroy_repo(target_repo, forks="detach")
fixture.destroy_repo(target_repo, forks_action=ForksAction.DETACH)
@pytest.mark.backends("hg", "git")
def test_set_fork_of_other_type_repo(self, autologin_user, backend, csrf_token):
@ -97,7 +98,10 @@ class TestAdminRepoSettingsAdvanced(object):
@pytest.mark.parametrize("suffix", ["", "ąęł", "123"], ids=no_newline_id_generator)
def test_advanced_repo_delete(self, autologin_user, backend, suffix, csrf_token):
repo = backend.create_repo(name_suffix=suffix)
repo = backend.create_repo(name_suffix=suffix, private_repo=True)
repo.private = True
Session().commit()
repo_name = repo.repo_name
repo_name_str = safe_str(repo.repo_name)

View file

@ -31,7 +31,7 @@ from rhodecode.lib.utils2 import safe_int
from rhodecode.lib.vcs import RepositoryError
from rhodecode.model.db import Session, UserFollowing, User, Repository
from rhodecode.model.permission import PermissionModel
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.scm import ScmModel
log = logging.getLogger(__name__)
@ -56,6 +56,9 @@ class RepoSettingsAdvancedView(RepoAppView):
c = self.load_default_context()
c.active = "advanced"
c.fork_links = self._resolve_repo_forks_links()
c.pr_link = h.route_path("pullrequest_show_all", repo_name=self.db_repo_name)
c.default_user_id = User.get_default_user_id()
c.in_public_journal = (
UserFollowing.query()
@ -80,6 +83,14 @@ class RepoSettingsAdvancedView(RepoAppView):
return self._get_template_context(c)
def _resolve_repo_forks_links(self):
def resolve_fork_link(repo):
for fork in repo.forks:
yield h.route_path("repo_summary", repo_name=fork.repo_name)
yield from resolve_fork_link(fork)
return list(resolve_fork_link(self.db_repo))
@LoginRequired()
@HasRepoPermissionAnyDecorator("repository.admin")
@CSRFRequired()
@ -124,11 +135,6 @@ class RepoSettingsAdvancedView(RepoAppView):
because of attached forks or other errors.
"""
_ = self.request.translate
handle_forks = self.request.POST.get("forks", None)
if handle_forks == "detach_forks":
handle_forks = "detach"
elif handle_forks == "delete_forks":
handle_forks = "delete"
repo_advanced_url = h.route_path("edit_repo_advanced", repo_name=self.db_repo_name, _anchor="advanced-delete")
try:
@ -137,14 +143,16 @@ class RepoSettingsAdvancedView(RepoAppView):
delete_cache = True
self._invalidate_remote_cache(delete=delete_cache)
RepoModel().delete(self.db_repo, forks=handle_forks)
delete_res = RepoModel().delete(self.db_repo)
_forks = self.db_repo.forks.count()
if _forks and handle_forks:
if handle_forks == "detach_forks":
h.flash(_("Detached %s forks") % _forks, category="success")
elif handle_forks == "delete_forks":
h.flash(_("Deleted %s forks") % _forks, category="success")
fork_action = delete_res.fork_action_result.action
fork_cnt = delete_res.fork_action_result.fork_count
if fork_cnt > 0 and fork_action is not None:
if fork_action is ForksAction.DETACH:
h.flash(_("Detached %s forks") % fork_cnt, category="success")
elif fork_action is ForksAction.DELETE:
h.flash(_("Deleted %s forks") % fork_cnt, category="success")
repo = audit_logger.RepoWrap(repo_id=None, repo_name=self.db_repo.repo_name)
audit_logger.store_web(

View file

@ -21,28 +21,22 @@ RhodeCode authentication plugin for LDAP
"""
import logging
import traceback
import colander
from rhodecode.authentication.plugins.services.ldap_dao import LdapDao
from rhodecode.translation import _
from rhodecode.authentication.base import RhodeCodeExternalAuthPlugin, AuthLdapBase, hybrid_property
from rhodecode.authentication.base import RhodeCodeExternalAuthPlugin, hybrid_property
from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase, TwoFactorAuthnPluginSettingsSchemaMixin
from rhodecode.authentication.routes import AuthnPluginResourceBase
from rhodecode.lib.colander_utils import strip_whitespace
from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
from rhodecode.lib.exceptions import LdapUsernameError, LdapPasswordError, LdapImportError
from rhodecode.lib.str_utils import safe_str
from rhodecode.model.db import User
from rhodecode.model.validators import Missing
log = logging.getLogger(__name__)
try:
import ldap
except ImportError:
# means that python-ldap is not installed, we use Missing object to mark
# ldap lib is Missing
ldap = Missing
class LdapError(Exception):
pass
@ -61,188 +55,6 @@ class LdapAuthnResource(AuthnPluginResourceBase):
pass
class AuthLdap(AuthLdapBase):
default_tls_cert_dir = "/etc/openldap/cacerts"
scope_labels = {
ldap.SCOPE_BASE: "SCOPE_BASE",
ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL",
ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE",
}
def __init__(
self,
server,
base_dn,
port=389,
bind_dn="",
bind_pass="",
tls_kind="PLAIN",
tls_reqcert="DEMAND",
tls_cert_file=None,
tls_cert_dir=None,
ldap_version=3,
search_scope="SUBTREE",
attr_login="uid",
ldap_filter="",
timeout=None,
):
if ldap == Missing:
raise LdapImportError("Missing or incompatible ldap library")
self.debug = False
self.timeout = timeout or 60 * 5
self.ldap_version = ldap_version
self.ldap_server_type = "ldap"
self.TLS_KIND = tls_kind
if self.TLS_KIND == "LDAPS":
port = port or 636
self.ldap_server_type += "s"
OPT_X_TLS_DEMAND = 2
self.TLS_REQCERT = getattr(ldap, "OPT_X_TLS_%s" % tls_reqcert, OPT_X_TLS_DEMAND)
self.TLS_CERT_FILE = tls_cert_file or ""
self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir
# split server into list
self.SERVER_ADDRESSES = self._get_server_list(server)
self.LDAP_SERVER_PORT = port
# USE FOR READ ONLY BIND TO LDAP SERVER
self.attr_login = attr_login
self.LDAP_BIND_DN = safe_str(bind_dn)
self.LDAP_BIND_PASS = safe_str(bind_pass)
self.SEARCH_SCOPE = getattr(ldap, "SCOPE_%s" % search_scope)
self.BASE_DN = safe_str(base_dn)
self.LDAP_FILTER = safe_str(ldap_filter)
def _get_ldap_conn(self):
if self.debug:
ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
if self.TLS_CERT_FILE and hasattr(ldap, "OPT_X_TLS_CACERTFILE"):
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE)
elif hasattr(ldap, "OPT_X_TLS_CACERTDIR"):
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR)
if self.TLS_KIND != "PLAIN":
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
# init connection now
ldap_servers = self._build_servers(self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT)
log.debug("initializing LDAP connection to:%s", ldap_servers)
ldap_conn = ldap.initialize(ldap_servers)
ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
ldap_conn.timeout = self.timeout
if self.ldap_version == 2:
ldap_conn.protocol = ldap.VERSION2
else:
ldap_conn.protocol = ldap.VERSION3
if self.TLS_KIND == "START_TLS":
ldap_conn.start_tls_s()
if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
log.debug("Trying simple_bind with password and given login DN: %r", self.LDAP_BIND_DN)
ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
log.debug("simple_bind successful")
return ldap_conn
def fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password):
scope = ldap.SCOPE_BASE
scope_label = self.scope_labels.get(scope)
ldap_filter = "(objectClass=*)"
try:
log.debug(
"Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)", dn, scope_label, ldap_filter
)
ldap_conn.simple_bind_s(dn, safe_str(password))
response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=["*", "+"])
if not response:
log.error("search bind returned empty results: %r", response)
return {}
else:
_dn, attrs = response[0]
return attrs
except ldap.INVALID_CREDENTIALS:
log.debug("LDAP rejected password for user '%s': %s, org_exc:", username, dn, exc_info=True)
def authenticate_ldap(self, username, password):
"""
Authenticate a user via LDAP and return his/her LDAP properties.
Raises AuthenticationError if the credentials are rejected, or
EnvironmentError if the LDAP server can't be reached.
:param username: username
:param password: password
"""
uid = self.get_uid(username, self.SERVER_ADDRESSES)
user_attrs = {}
dn = ""
self.validate_password(username, password)
self.validate_username(username)
scope_label = self.scope_labels.get(self.SEARCH_SCOPE)
ldap_conn = None
try:
ldap_conn = self._get_ldap_conn()
filter_ = "(&{}({}={}))".format(self.LDAP_FILTER, self.attr_login, username)
log.debug("Authenticating %r filter %s and scope: %s", self.BASE_DN, filter_, scope_label)
ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=["*", "+"])
if not ldap_objects:
log.debug("No matching LDAP objects for authentication of UID:'%s' username:(%s)", uid, username)
raise ldap.NO_SUCH_OBJECT()
log.debug("Found %s matching ldap object[s], trying to authenticate on each one now...", len(ldap_objects))
for dn, _attrs in ldap_objects:
if dn is None:
continue
user_attrs = self.fetch_attrs_from_simple_bind(ldap_conn, dn, username, password)
if user_attrs:
log.debug("Got authenticated user attributes from DN:%s", dn)
break
else:
raise LdapPasswordError(f"Failed to authenticate user `{username}` with given password")
except ldap.NO_SUCH_OBJECT:
log.debug("LDAP says no such user '%s' (%s), org_exc:", uid, username, exc_info=True)
raise LdapUsernameError("Unable to find user")
except ldap.SERVER_DOWN:
org_exc = traceback.format_exc()
raise LdapConnectionError("LDAP can't access authentication server, org_exc:%s" % org_exc)
finally:
if ldap_conn:
log.debug("ldap: connection release")
try:
ldap_conn.unbind_s()
except Exception:
# for any reason this can raise exception we must catch it
# to not crush the server
pass
return dn, user_attrs
class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSettingsSchemaBase):
tls_kind_choices = ["PLAIN", "LDAPS", "START_TLS"]
tls_reqcert_choices = ["NEVER", "ALLOW", "TRY", "DEMAND", "HARD"]
@ -338,7 +150,7 @@ class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSet
)
tls_cert_dir = colander.SchemaNode(
colander.String(),
default=AuthLdap.default_tls_cert_dir,
default=LdapDao.default_tls_cert_dir,
description=_(
"This specifies the path of a directory that contains individual CA certificates in separate files."
),
@ -420,6 +232,38 @@ class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSet
title=_("Last Name Attribute"),
widget="string",
)
sync_active_directory_users = colander.SchemaNode(
colander.Bool(),
default=False,
description=_(
"A cron job that periodically retrieves all users from an LDAP-based Active Directory server and syncs "
"them with the RhodeCode database.\n"
"Note: This feature is specific to Active Directory. Enabling it for other types of LDAP servers will have no effect."
),
missing=False,
preparer=strip_whitespace,
title=_("Sync Active Directory Users"),
widget="bool",
)
def get_ldap_args(settings: dict):
return {
"server": settings.get("host", ""),
"base_dn": settings.get("base_dn", ""),
"port": settings.get("port"),
"bind_dn": settings.get("dn_user"),
"bind_pass": settings.get("dn_pass"),
"tls_kind": settings.get("tls_kind"),
"tls_reqcert": settings.get("tls_reqcert"),
"tls_cert_file": settings.get("tls_cert_file"),
"tls_cert_dir": settings.get("tls_cert_dir"),
"search_scope": settings.get("search_scope"),
"attr_login": settings.get("attr_login"),
"ldap_version": 3,
"ldap_filter": settings.get("filter"),
"timeout": settings.get("timeout"),
}
class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
@ -509,30 +353,15 @@ class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
log.debug("Empty username or password skipping...")
return None
ldap_args = {
"server": settings.get("host", ""),
"base_dn": settings.get("base_dn", ""),
"port": settings.get("port"),
"bind_dn": settings.get("dn_user"),
"bind_pass": settings.get("dn_pass"),
"tls_kind": settings.get("tls_kind"),
"tls_reqcert": settings.get("tls_reqcert"),
"tls_cert_file": settings.get("tls_cert_file"),
"tls_cert_dir": settings.get("tls_cert_dir"),
"search_scope": settings.get("search_scope"),
"attr_login": settings.get("attr_login"),
"ldap_version": 3,
"ldap_filter": settings.get("filter"),
"timeout": settings.get("timeout"),
}
ldap_args = get_ldap_args(settings)
ldap_attrs = self.try_dynamic_binding(username, password, ldap_args)
log.debug("Checking for ldap authentication.")
try:
auth_ldap = AuthLdap(**ldap_args)
(user_dn, ldap_attrs) = auth_ldap.authenticate_ldap(username, password)
ldap_dao = LdapDao(**ldap_args)
(user_dn, ldap_attrs) = ldap_dao.authenticate_ldap(username, password)
log.debug("Got ldap DN response %s", user_dn)
def get_ldap_attr(k) -> str:

View file

@ -0,0 +1,219 @@
import logging
import traceback
from dataclasses import dataclass
from typing import List, Optional
from rhodecode.model.validators import Missing
from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
from rhodecode.authentication.base import AuthLdapBase
from rhodecode.lib.str_utils import safe_str
try:
import ldap
except ImportError:
# means that python-ldap is not installed, we use Missing object to mark
# ldap lib is Missing
ldap = Missing
class LdapDao(AuthLdapBase):
default_tls_cert_dir = "/etc/openldap/cacerts"
scope_labels = {
ldap.SCOPE_BASE: "SCOPE_BASE",
ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL",
ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE",
}
def __init__(
self,
server,
base_dn,
port=389,
bind_dn="",
bind_pass="",
tls_kind="PLAIN",
tls_reqcert="DEMAND",
tls_cert_file=None,
tls_cert_dir=None,
ldap_version=3,
search_scope="SUBTREE",
attr_login="uid",
ldap_filter="",
timeout=None,
):
if ldap == Missing:
raise LdapImportError("Missing or incompatible ldap library")
self.log = logging.getLogger(LdapDao.__name__)
self.debug = False
self.timeout = timeout or 60 * 5
self.ldap_version = ldap_version
self.ldap_server_type = "ldap"
self.TLS_KIND = tls_kind
if self.TLS_KIND == "LDAPS":
port = port or 636
self.ldap_server_type += "s"
OPT_X_TLS_DEMAND = 2
self.TLS_REQCERT = getattr(ldap, "OPT_X_TLS_%s" % tls_reqcert, OPT_X_TLS_DEMAND)
self.TLS_CERT_FILE = tls_cert_file or ""
self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir
# split server into list
self.SERVER_ADDRESSES = self._get_server_list(server)
self.LDAP_SERVER_PORT = port
# USE FOR READ ONLY BIND TO LDAP SERVER
self.attr_login = attr_login
self.LDAP_BIND_DN = safe_str(bind_dn)
self.LDAP_BIND_PASS = safe_str(bind_pass)
self.SEARCH_SCOPE = getattr(ldap, "SCOPE_%s" % search_scope)
self.BASE_DN = safe_str(base_dn)
self.LDAP_FILTER = safe_str(ldap_filter)
def _get_ldap_conn(self):
if self.debug:
ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
if self.TLS_CERT_FILE and hasattr(ldap, "OPT_X_TLS_CACERTFILE"):
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE)
elif hasattr(ldap, "OPT_X_TLS_CACERTDIR"):
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR)
if self.TLS_KIND != "PLAIN":
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
# init connection now
ldap_servers = self._build_servers(self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT)
self.log.debug("initializing LDAP connection to:%s", ldap_servers)
ldap_conn = ldap.initialize(ldap_servers)
ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
ldap_conn.timeout = self.timeout
if self.ldap_version == 2:
ldap_conn.protocol = ldap.VERSION2
else:
ldap_conn.protocol = ldap.VERSION3
if self.TLS_KIND == "START_TLS":
ldap_conn.start_tls_s()
if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
self.log.debug("Trying simple_bind with password and given login DN: %r", self.LDAP_BIND_DN)
ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
self.log.debug("simple_bind successful")
return ldap_conn
def fetch_all(self, ldap_filter: str = "(objectClass=*)", attributes: List[str] = None) -> Optional[List[dict]]:
ldap_conn = None
try:
if attributes is None:
attributes = ["*", "+"]
ldap_conn = self._get_ldap_conn()
self.log.debug("fetching users for DN: %s", self.BASE_DN)
ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, ldap_filter, attrlist=attributes)
return [attrs for _, attrs in ldap_objects]
except Exception as e:
self.log.error("Error fetching users for DN: %s. Error: %s", self.BASE_DN, str(e))
return None
finally:
self._releease_connection(ldap_conn)
def _releease_connection(self, ldap_conn):
if ldap_conn:
self.log.debug("ldap: connection release")
try:
ldap_conn.unbind_s()
except Exception as e:
# for any reason this can raise exception we must catch it
# to not crush the server
self.log.warning("unbind_s failed, error: %s", str(e))
def _fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password):
scope = ldap.SCOPE_BASE
scope_label = self.scope_labels.get(scope)
ldap_filter = "(objectClass=*)"
try:
self.log.debug(
"Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)", dn, scope_label, ldap_filter
)
ldap_conn.simple_bind_s(dn, safe_str(password))
response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=["*", "+"])
if not response:
self.log.error("search bind returned empty results: %r", response)
return {}
else:
_dn, attrs = response[0]
return attrs
except ldap.INVALID_CREDENTIALS:
self.log.debug("LDAP rejected password for user '%s': %s, org_exc:", username, dn, exc_info=True)
def authenticate_ldap(self, username, password):
"""
Authenticate a user via LDAP and return his/her LDAP properties.
Raises AuthenticationError if the credentials are rejected, or
EnvironmentError if the LDAP server can't be reached.
:param username: username
:param password: password
"""
uid = self.get_uid(username, self.SERVER_ADDRESSES)
self.validate_password(username, password)
self.validate_username(username)
scope_label = self.scope_labels.get(self.SEARCH_SCOPE)
ldap_conn = None
try:
ldap_conn = self._get_ldap_conn()
filter_ = "(&{}({}={}))".format(self.LDAP_FILTER, self.attr_login, username)
self.log.debug("Authenticating %r filter %s and scope: %s", self.BASE_DN, filter_, scope_label)
ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=["*", "+"])
if not ldap_objects:
self.log.debug("No matching LDAP objects for authentication of UID:'%s' username:(%s)", uid, username)
raise ldap.NO_SUCH_OBJECT()
self.log.debug(
"Found %s matching ldap object[s], trying to authenticate on each one now...", len(ldap_objects)
)
for dn, _attrs in ldap_objects:
if dn is None:
continue
user_attrs = self._fetch_attrs_from_simple_bind(ldap_conn, dn, username, password)
if user_attrs:
self.log.debug("Got authenticated user attributes from DN:%s", dn)
break
else:
raise LdapPasswordError(f"Failed to authenticate user `{username}` with given password")
except ldap.NO_SUCH_OBJECT:
self.log.debug("LDAP says no such user '%s' (%s), org_exc:", uid, username, exc_info=True)
raise LdapUsernameError("Unable to find user")
except ldap.SERVER_DOWN:
org_exc = traceback.format_exc()
raise LdapConnectionError("LDAP can't access authentication server, org_exc:%s" % org_exc)
finally:
self._releease_connection(ldap_conn)
return dn, user_attrs

View file

@ -0,0 +1,52 @@
import unittest
from unittest.mock import patch, MagicMock
import ldap
from rhodecode.authentication.plugins.services.ldap_dao import LdapDao
from rhodecode.lib.diff_match_patch import patch_obj
@patch("rhodecode.authentication.plugins.services.ldap_dao.ldap")
class TestLdapDao(unittest.TestCase):
def setUp(self):
self._server_list = "test_srv1,test_srv2,test_srv3"
self._base_dn = "test_dn"
self.ldap_dao = LdapDao(
server=self._server_list,
base_dn=self._base_dn,
)
@patch.object(LdapDao, "_get_ldap_conn")
def test_fetch_all_from_ldap_server(self, _get_ldap_conn_mock, ldap_mock):
conn = MagicMock()
no_objects_in_ldap_server = []
conn.search_ext_s.return_value = no_objects_in_ldap_server
_get_ldap_conn_mock.return_value = conn
returned_value = self.ldap_dao.fetch_all()
conn.search_ext_s.assert_called_once_with(self._base_dn, 2, "(objectClass=*)", attrlist=["*", "+"])
assert returned_value == no_objects_in_ldap_server
@patch.object(LdapDao, "_get_ldap_conn")
def test_fetch_all_release_connection(self, _get_ldap_conn_mock, ldap_mock):
conn = MagicMock()
_get_ldap_conn_mock.return_value = conn
self.ldap_dao.fetch_all()
conn.unbind_s.assert_called_once()
@patch.object(LdapDao, "_get_ldap_conn")
def test_fetch_all_release_connection_on_error(self, _get_ldap_conn_mock, ldap_mock):
conn = MagicMock()
_get_ldap_conn_mock.return_value = conn
conn.search_ext_s.side_effect = Exception("Test exception")
res = self.ldap_dao.fetch_all()
assert res is None
conn.unbind_s.assert_called_once()

View file

@ -0,0 +1,63 @@
import unittest
from unittest.mock import MagicMock, patch
import pytest
from rhodecode.authentication.views import AuthnPluginViewBase
from rhodecode.lib.celerylib import tasks
class TestAuthPluginView:
@pytest.mark.parametrize(
"sync_active_directory_users, expected_celery_task",
[
(
True,
tasks.schedule_sync_ldap_ad_users_producer,
),
(
False,
tasks.unschedule_sync_ldap_ad_users_producer,
),
],
)
@patch("rhodecode.apps._base.BaseAppView.__init__", return_value=None)
@patch("rhodecode.authentication.views.h")
@patch("rhodecode.authentication.views.SettingsModel")
@patch.object(AuthnPluginViewBase, "load_default_context")
@patch("rhodecode.authentication.views.run_task")
def test_enable_ad_sync_schedule(
self,
run_task_mock,
load_default_context,
settings_model,
h,
_init_,
sync_active_directory_users,
expected_celery_task,
):
view = self._get_instance()
settings_post = AuthnPluginViewBase.__dict__[ # ignore decorators
"settings_post"
].__wrapped__.__wrapped__.__wrapped__
schema = MagicMock()
schema.deserialize.return_value = {"sync_active_directory_users": sync_active_directory_users}
self._plugin.get_settings_schema.return_value = schema
settings_post(view)
run_task_mock.assert_called_once_with(expected_celery_task)
def _get_instance(self):
# since base __init__ was patched, there is a need to inject mocks manually
self._context = MagicMock()
self._request = MagicMock()
self._plugin = MagicMock()
view = AuthnPluginViewBase(self._context, self._request)
view.request = self._request
view.plugin = self._plugin
view.context = self._context
return view

View file

@ -28,6 +28,7 @@ from rhodecode.apps._base import BaseAppView
from rhodecode.authentication.base import get_authn_registry
from rhodecode.lib import helpers as h
from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator, CSRFRequired
from rhodecode.lib.celerylib import run_task, tasks
from rhodecode.model.forms import AuthSettingsForm
from rhodecode.model.meta import Session
from rhodecode.model.settings import SettingsModel
@ -90,6 +91,9 @@ class AuthnPluginViewBase(BaseAppView):
# Store validated data.
for name, value in valid_data.items():
self.plugin.create_or_update_setting(name, value)
if name == "sync_active_directory_users":
self._handle_ad_users_sync_schedule(name, value)
Session().commit()
SettingsModel().invalidate_settings_cache()
@ -102,6 +106,17 @@ class AuthnPluginViewBase(BaseAppView):
return HTTPFound(redirect_to)
def _handle_ad_users_sync_schedule(self, name: str, enabled: bool):
if name != "sync_active_directory_users":
return
if enabled:
log.debug("Scheduling AD users sync")
run_task(tasks.schedule_sync_ldap_ad_users_producer)
else:
log.debug("Removing schedule for AD users sync")
run_task(tasks.unschedule_sync_ldap_ad_users_producer)
class AuthSettingsView(BaseAppView):
def load_default_context(self):

View file

@ -22,36 +22,12 @@ import logging
from pyramid.settings import asbool
from rhodecode.config.settings_maker import SettingsMaker
from rhodecode.config.settings_maker import SettingsMaker, generate_token
from rhodecode.config import utils as config_utils
log = logging.getLogger(__name__)
def initialize_ini_config_default_values_if_not_present(ini_path: str):
from configupdater import ConfigUpdater # use configupdater to not break comments and formatting's
def dump_config():
with open(ini_path, "w") as configfile:
updater.write(configfile)
updater = ConfigUpdater()
updater.read(ini_path)
section = "app:main"
option = "app.service_api.token"
if not updater[section][option].value.strip():
updater[section][option] = generate_token()
dump_config()
def generate_token(length: int = 32) -> str:
import secrets
return secrets.token_urlsafe(length)
def sanitize_settings_and_apply_defaults(global_config, settings):
"""
Applies settings defaults and does all type conversion.
@ -180,8 +156,8 @@ def sanitize_settings_and_apply_defaults(global_config, settings):
settings_maker.make_setting(
"exception_tracker.store_path",
default=jn(default_cache_dir, "exc_store"),
default_when_empty=True,
parser="dir:ensured",
default_when_empty=True,
)
settings_maker.make_setting(
@ -279,11 +255,11 @@ def sanitize_settings_and_apply_defaults(global_config, settings):
settings_maker.make_setting("archive_cache.objectstore.retry_backoff", 1, parser="int")
settings_maker.make_setting("archive_cache.objectstore.retry_attempts", 10, parser="int")
settings_maker.make_setting("app.service_api.token", generate_token(), parser="string", write_default_to_ini=True)
settings_maker.env_expand()
# configure instance id
config_utils.set_instance_id(settings)
initialize_ini_config_default_values_if_not_present(global_config.get("__file__"))
return settings

View file

@ -33,6 +33,33 @@ log = logging.getLogger(__name__)
set_keys = {"__file__": ""}
def initialize_ini_config_default_values_if_not_present(
ini_path: str, option: str, default_val: object, section: str = "app:main"
):
from configupdater import ConfigUpdater # use configupdater to not break comments and formatting's
def dump_config():
with open(ini_path, "w") as configfile:
updater.write(configfile)
if not ini_path or not os.path.exists(ini_path):
log.warning("Config file %s not found.", ini_path)
return
updater = ConfigUpdater()
updater.read(ini_path)
if option not in updater[section] or not updater[section][option].value.strip():
updater[section][option] = default_val
dump_config()
def generate_token(length: int = 32) -> str:
import secrets
return secrets.token_urlsafe(length)
class SettingsMaker:
def __init__(self, app_settings):
self.settings = app_settings
@ -154,9 +181,16 @@ class SettingsMaker:
logging.config.fileConfig(f.name)
os.remove(f.name)
def make_setting(self, key, default, lower=False, default_when_empty=False, parser=None):
def make_setting(
self, key, default, lower=False, default_when_empty=False, parser=None, write_default_to_ini=False
):
input_val = self.settings.get(key, default)
if write_default_to_ini:
initialize_ini_config_default_values_if_not_present(
ini_path=self.settings.get("__file__"), option=key, default_val=default
)
if default_when_empty and not input_val:
# use default value when value is set in the config but it is empty
input_val = default

View file

@ -2583,10 +2583,10 @@ msgstr ""
msgid "Confirm to delete this repository"
msgstr ""
#: rhodecode/templates/admin/repos/repo_edit.html:310
#: rhodecode/templates/admin/repos/repo_edit.html:208
#, python-format
msgid "this repository has %s fork"
msgid_plural "this repository has %s forks"
msgid "Following fork will be %s"
msgid_plural "Following forks will be %s"
msgstr[0] ""
msgstr[1] ""

View file

@ -7567,7 +7567,7 @@ msgstr ""
msgid "Delete this repository"
msgstr ""
#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:242
#: rhodecode/templates/admin/repos/repo_edit_advanced.mako:249
msgid "This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools."
msgstr ""

View file

@ -24,6 +24,7 @@ by celery daemon
import os
import time
from celery import current_app
from pyramid_mailer.mailer import Mailer
from pyramid_mailer.message import Message
from email.utils import formatdate
@ -35,7 +36,7 @@ from rhodecode.lib import hooks_base
from rhodecode.lib.utils import adopt_for_celery
from rhodecode.lib.utils2 import safe_int, str2bool, aslist
from rhodecode.lib.statsd_client import StatsdClient
from rhodecode.model.db import true, null, Session, IntegrityError, Repository, RepoGroup, User
from rhodecode.model.db import true, null, Session, IntegrityError, Repository, RepoGroup, User, ScheduleEntry
from rhodecode.model.permission import PermissionModel
@ -417,6 +418,47 @@ def beat_check(*args, **kwargs):
return time.time()
@async_task(ignore_result=True)
def schedule_sync_ldap_ad_users_producer():
log = get_logger(schedule_sync_ldap_ad_users_producer)
try:
from rc_ee.lib.celerylib.scheduler import RcScheduler
except ImportError:
log.error("Attempt to schedule EE feature")
return
scheduler = RcScheduler(app=current_app)
scheduler.sync()
task_name = "rc_ee.lib.celerylib.tasks.sync_ldap_ad_users_producer"
if task_name not in scheduler.schedule:
entries = {
task_name: {
"task": task_name,
"schedule_type": "crontab",
"schedule_value": {"hour": 0, "minute": 4},
"options": {"expires": 12 * 3600},
}
}
scheduler.update_from_dict(entries)
@async_task(ignore_result=True)
def unschedule_sync_ldap_ad_users_producer():
log = get_logger(unschedule_sync_ldap_ad_users_producer)
try:
from rc_ee.lib.celerylib.scheduler import RcScheduler
except ImportError:
log.error("Attempt to unschedule EE feature")
return
task_name = "rc_ee.lib.celerylib.tasks.sync_ldap_ad_users_producer"
existing_task = ScheduleEntry.query().filter(ScheduleEntry.task_dot_notation == task_name).first()
if existing_task:
Session().delete(existing_task)
@async_task
@adopt_for_celery
def repo_size(extras):

View file

@ -19,7 +19,7 @@
import time
import logging
from rhodecode.config.config_maker import initialize_ini_config_default_values_if_not_present
from rhodecode.config.settings_maker import initialize_ini_config_default_values_if_not_present, generate_token
from rhodecode.lib.config_utils import get_app_config_lightweight
from rhodecode.lib.hook_daemon.base import Hooks
@ -34,7 +34,9 @@ def prepare_callback_daemon(extras, protocol: str, txn_id=None):
hooks_config = {}
match protocol:
case "celery":
initialize_ini_config_default_values_if_not_present(extras["config"])
initialize_ini_config_default_values_if_not_present(
ini_path=extras["config"], option="app.service_api.token", default_val=generate_token()
)
config = get_app_config_lightweight(extras["config"])
broker_url = config.get("celery.broker_url")

View file

@ -21,19 +21,20 @@ import os
from pyramid.paster import bootstrap as pyramid_bootstrap, setup_logging # pragma: no cover
from pyramid.threadlocal import get_current_request as pyramid_current_request
from rhodecode.config.config_maker import initialize_ini_config_default_values_if_not_present
def bootstrap(config_uri, options=None, env=None):
from rhodecode.config.utils import DEFAULT_USER
from rhodecode.lib.config_utils import get_app_config_lightweight
from rhodecode.lib.utils2 import AttributeDict
from rhodecode.lib.request import Request
from rhodecode.config.settings_maker import initialize_ini_config_default_values_if_not_present, generate_token
if env:
os.environ.update(env)
initialize_ini_config_default_values_if_not_present(config_uri)
initialize_ini_config_default_values_if_not_present(
ini_path=config_uri, option="app.service_api.token", default_val=generate_token()
)
config = get_app_config_lightweight(config_uri)
base_url = config["app.base_url"]

View file

@ -57,7 +57,6 @@ from rhodecode.model import meta
from rhodecode.model.db import Repository, User, RhodeCodeUi, UserLog, RepoGroup, UserGroup
from rhodecode.model.meta import Session
log = logging.getLogger(__name__)
REMOVED_REPO_PAT = re.compile(r"rm__\d{8}_\d{6}_\d{6}__.*")
@ -665,7 +664,7 @@ def repo2db_mapper(initial_repo_list, force_hooks_rebuild=False):
def repo2db_cleanup(skip_repos=None, skip_groups=None):
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.repo_group import RepoGroupModel
sa = meta.Session()
@ -688,7 +687,7 @@ def repo2db_cleanup(skip_repos=None, skip_groups=None):
if not instance:
log.debug("Removing non-existing repository found in db `%s`", db_repo_name)
try:
RepoModel(sa).delete(db_repo, forks="detach", fs_remove=False, call_events=False)
RepoModel(sa).delete(db_repo, forks_action=ForksAction.DETACH, fs_remove=False, call_events=False)
sa.commit()
removed.append(db_repo_name)
except Exception:

View file

@ -330,11 +330,24 @@ def age(prevdate, now=None, show_short_version=False, show_suffix=True, short_fo
def get_year(prevdate):
return prevdate.year
def _normalize_datetime_pair(prevdate, now):
"""
Ensure both datetime objects are the same kind (either both naive or both timezone-aware).
If not, comparing them will raise a TypeError due to mismatched timezone awareness.
"""
if prevdate.tzinfo or now.tzinfo:
utc = datetime.timezone.utc
return prevdate.replace(tzinfo=utc), now.replace(tzinfo=utc)
return prevdate, now # no timezone
now = now or datetime.datetime.now()
order = ["year", "month", "day", "hour", "minute", "second"]
deltas = {}
future = False
prevdate, now = _normalize_datetime_pair(prevdate, now)
if prevdate > now:
now_old = now
now = prevdate

View file

@ -2411,6 +2411,17 @@ class MergeCheck(object):
return merge_check
log.debug("MergeCheck: is failed: %s", merge_check.failed)
if merge_check.failed:
close_branch = PullRequestModel()._close_branch_before_merging(pull_request)
if close_branch:
close_msg = cls._get_branch_close_or_delete_message(_, pull_request)
merge_check.error_details["extra_info"] = {
"details": "detailed_info",
"message": close_msg,
"error_type": "extra_info",
}
return merge_check
@classmethod
@ -2428,17 +2439,29 @@ class MergeCheck(object):
close_branch = model._close_branch_before_merging(pull_request)
if close_branch:
repo_type = pull_request.target_repo.repo_type
close_msg = ""
if repo_type == "hg":
close_msg = _("Source branch will be closed before the merge.")
elif repo_type == "git":
close_msg = _("Source branch will be deleted after the merge.")
close_msg = cls._get_branch_close_or_delete_message(_, pull_request)
merge_details["close_branch"] = dict(details={}, message=close_msg)
return merge_details
@classmethod
def _get_branch_close_or_delete_message(cls, translator, pull_request):
_ = translator
repo_type = pull_request.target_repo.repo_type
close_msg = ""
branch_name = pull_request.source_ref_parts.name
max_branch_length = 20
shorten_branch_name = (
f"{branch_name[:max_branch_length]}..." if len(branch_name) > max_branch_length else branch_name
)
message_template = "Source branch '%s' will be {action} before the merge." % shorten_branch_name
if repo_type == "hg":
close_msg = _(message_template.format(action="closed"))
elif repo_type == "git":
close_msg = _(message_template.format(action="deleted"))
return close_msg
@dataclasses.dataclass
class ChangeTuple:

View file

@ -15,7 +15,7 @@
# This program is dual-licensed. If you wish to learn more about the
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/
import itertools
import os
import re
import shutil
@ -23,6 +23,8 @@ import time
import logging
import traceback
import datetime
from dataclasses import dataclass, field
from enum import StrEnum, auto
from sqlalchemy.orm import aliased
from zope.cachedescriptors.property import Lazy as LazyProperty
@ -69,11 +71,32 @@ from rhodecode.model.db import (
UserLog,
)
from rhodecode.model.permission import PermissionModel
from rhodecode.model.settings import VcsSettingsModel
log = logging.getLogger(__name__)
class ForksAction(StrEnum):
DELETE = auto()
DETACH = auto()
UNKNOWN = auto()
@classmethod
def _missing_(cls, value):
return cls(cls.UNKNOWN)
@dataclass
class ForkActionResult:
fork_count: int = 0
action: ForksAction = None
@dataclass
class DeleteRepoResult:
is_success: bool
fork_action_result: ForkActionResult = field(default_factory=ForkActionResult)
class RepoModel(BaseModel):
cls = Repository
@ -750,47 +773,49 @@ class RepoModel(BaseModel):
raise
def delete(
self, repo, forks=None, pull_requests=None, artifacts=None, fs_remove=True, cur_user=None, call_events=True
):
self,
repo: Repository,
forks_action: ForksAction = None, # used only in API to enforce action
fs_remove: bool = True,
cur_user: User = None,
call_events: bool = True,
) -> DeleteRepoResult:
"""
Delete given repository, forks parameter defines what do do with
attached forks. Throws AttachedForksError if deleted repo has attached
forks
Delete a given repository.
Throws AttachedForksError if a deleted repo has attached forks
:param repo:
:param forks: str 'delete' or 'detach'
:param pull_requests: str 'delete' or None
:param artifacts: str 'delete' or None
:param forks_action:
NOTE: used only in API to enforce action;
If the repository is private, then forks will be removed.
If the repository is public, then forks will be detached.
:param fs_remove: remove(archive) repo from filesystem
"""
if not cur_user:
cur_user = getattr(get_current_rhodecode_user(), "username", None)
repo = self._get_repo(repo)
if not repo:
return False
return DeleteRepoResult(is_success=False)
if forks == "detach":
if not cur_user:
cur_user = getattr(get_current_rhodecode_user(), "username", None)
if forks_action is None or forks_action is ForksAction.UNKNOWN:
is_private = repo.private
forks_action = ForksAction.DELETE if is_private else ForksAction.DETACH
forks_cnt = repo.forks.count()
if forks_action is ForksAction.DETACH:
for r in repo.forks:
r.fork = None
self.sa.add(r)
elif forks == "delete":
elif forks_action is ForksAction.DELETE:
for r in repo.forks:
self.delete(r, forks="delete")
elif [f for f in repo.forks]:
self.delete(r, forks_action=forks_action)
elif repo.forks.first() is not None:
raise AttachedForksError()
# check for pull requests
pr_sources = repo.pull_requests_source
pr_targets = repo.pull_requests_target
if pull_requests != "delete" and (pr_sources or pr_targets):
raise AttachedPullRequestsError()
artifacts_objs = repo.artifacts
if artifacts == "delete":
for a in artifacts_objs:
self.sa.delete(a)
elif [a for a in artifacts_objs]:
raise AttachedArtifactsError()
self._delete_repo_related_assets(repo)
old_repo_dict = repo.get_dict()
if call_events:
@ -815,7 +840,17 @@ class RepoModel(BaseModel):
log.error(traceback.format_exc())
raise
return True
return DeleteRepoResult(
is_success=True, fork_action_result=ForkActionResult(action=forks_action, fork_count=forks_cnt)
)
def _delete_repo_related_assets(self, repo):
pr_sources = repo.pull_requests_source
pr_targets = repo.pull_requests_target
artifacts = repo.artifacts
logs = repo.logs
for obj_to_remove in itertools.chain(pr_sources, pr_targets, artifacts, logs):
self.sa.delete(obj_to_remove)
def grant_user_permission(self, repo, user, perm):
"""

View file

@ -3,8 +3,8 @@ var AgeModule = (function () {
return {
age: function(prevdate, now, show_short_version, show_suffix, short_format) {
var prevdate = moment(prevdate);
var now = now || moment().utc();
var prevdate = moment(prevdate).utc();
var now = now ? moment(now).utc() : moment().utc();
var show_short_version = show_short_version || false;
var show_suffix = show_suffix || true;

View file

@ -200,50 +200,42 @@
<div class="panel-body">
${h.secure_form(h.route_path('edit_repo_advanced_delete', repo_name=c.repo_name), request=request)}
<table class="display">
<% forks = len(c.fork_links) %>
<% delete_forks = c.rhodecode_db_repo.private %>
% if forks:
<tr>
<td>
${_ungettext('This repository has %s fork.', 'This repository has %s forks.', c.rhodecode_db_repo.forks.count()) % c.rhodecode_db_repo.forks.count()}
<td class="alert-${'error' if delete_forks else 'warning'}" style="padding: 10px">
${_ungettext('Following fork will be %s:', 'Following forks will be %s:', forks) % ('deleted' if delete_forks else 'detached')}
</td>
<td>
%if c.rhodecode_db_repo.forks.count():
<input type="radio" name="forks" value="detach_forks" checked="checked"/> <label for="forks">${_('Detach forks')}</label>
%endif
</td>
<td>
%if c.rhodecode_db_repo.forks.count():
<input type="radio" name="forks" value="delete_forks"/> <label for="forks">${_('Delete forks')}</label>
%endif
<ul>
% for fork in c.fork_links:
<li class="list-unstyled"><a href="${fork}" target="_blank" rel="noopener noreferrer">${fork.replace("/", "")} <span aria-hidden="true" style="font-size: 0.9em;">↗️</span></a></li>
% endfor
</ul>
</td>
</tr>
% endif
<% attached_prs = len(c.rhodecode_db_repo.pull_requests_source + c.rhodecode_db_repo.pull_requests_target) %>
% if c.rhodecode_db_repo.pull_requests_source or c.rhodecode_db_repo.pull_requests_target:
% if attached_prs:
<tr>
<td>
${_ungettext('This repository has %s attached pull request.', 'This repository has %s attached pull requests.', attached_prs) % attached_prs}
<br/>
<br/>
<strong>${_('Consider to archive this repository instead.')}</strong>
<td colspan="2" style="padding: 10px">
<a href="${c.pr_link}" target="_blank" rel="noopener noreferrer">
${_ungettext('This repository has %s attached pull request (open/close).', 'This repository has %s attached pull requests (open/close).', attached_prs) % attached_prs} <span aria-hidden="true" style="font-size: 0.9em;">↗️</span>
</a>
</td>
<td></td>
<td></td>
</tr>
% endif
<% attached_artifacts = len(c.rhodecode_db_repo.artifacts) %>
% if attached_artifacts:
<tr>
<td>
<td colspan="2">
${_ungettext('This repository has %s attached artifact.', 'This repository has %s attached artifacts.', attached_artifacts) % attached_artifacts}
<br/>
<br/>
<strong>${_('Consider to archive this repository instead.')}</strong>
</td>
<td></td>
<td></td>
</tr>
% endif
</table>
<div style="margin: 0 0 20px 0" class="fake-space"></div>
@ -259,7 +251,6 @@
${_('This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools.')}
</span>
</div>
${h.end_form()}
</div>
</div>

View file

@ -4,6 +4,7 @@
</div>
<div class="panel-body">
<dl class="dl-horizontal">
% if c.statistics:
% for stat in c.statistics:
% if stat.get('sep'):
<dt></dt>
@ -13,6 +14,18 @@
<dd>${stat['value']}</dd>
% endif
% endfor
% endif
% if c.error:
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title">${_('ERROR')}</h3>
</div>
<div class="panel-body">
${c.error}
</div>
</div>
% endif
</dl>
</div>
</div>

View file

@ -136,7 +136,7 @@
</div>
<div class="input">
${h.select('extern_type', c.extern_type, c.allowed_extern_types)}
<p class="help-block">${_('When user was created using an external source. He is bound to authentication using this method.')}</p>
<p class="help-block">${_('When user was created using an external source, he is bound to authentication using this method. If this set to "internal" user can login using RhodeCode internal auth and external identity. If this is set to some external auth system, internal login/password auth will be disabled.')}</p>
</div>
</div>
<div class="field">

View file

@ -294,7 +294,7 @@ class Fixture(object):
return r
def destroy_repo(self, repo_name, **kwargs):
RepoModel().delete(repo_name, pull_requests="delete", artifacts="delete", **kwargs)
RepoModel().delete(repo_name, **kwargs)
Session().commit()
def destroy_repo_on_filesystem(self, repo_name):

View file

@ -177,6 +177,32 @@ def test_age(age_args, expected, kw, baseapp):
assert translate(age(n + delt(**age_args), now=n, **kw)) == expected
@pytest.mark.parametrize(
"prevdate_fn, now_fn",
[
(functools.partial(datetime.datetime.now, tz=datetime.timezone.max), datetime.datetime.now),
(datetime.datetime.now, datetime.datetime.now),
(datetime.datetime.now, functools.partial(datetime.datetime.now, tz=datetime.timezone.max)),
(
functools.partial(datetime.datetime.now, tz=datetime.timezone.max),
functools.partial(datetime.datetime.now, tz=datetime.timezone.max),
),
(functools.partial(datetime.datetime.now, tz=datetime.timezone.max), lambda: None),
(datetime.datetime.now, lambda: None),
],
)
def test_age_timezone_aware(now_fn, prevdate_fn):
from rhodecode.lib.utils2 import age
now = now_fn()
prevdate = prevdate_fn()
try:
age(prevdate, now)
except Exception as e:
pytest.fail(f"Function raised an exception: {e}")
@pytest.mark.parametrize(
"age_args, expected, kw",
[
@ -326,19 +352,19 @@ def test_metatag_extraction(sample, expected_tags):
(("lang", "[lang =&gt; .NET]"), '<div class="metatag" tag="lang">.NET</div>'),
(
("license", "[license =&gt; BSD 3-clause]"),
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/BSD 3-clause">BSD 3-clause</a></div>',
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/BSD 3-clause">BSD 3-clause</a></div>', # noqa: W605
),
(
("license", "[license =&gt; GPLv3]"),
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/GPLv3">GPLv3</a></div>',
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/GPLv3">GPLv3</a></div>', # noqa: W605
),
(
("license", "[license =&gt; MIT]"),
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/MIT">MIT</a></div>',
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/MIT">MIT</a></div>', # noqa: W605
),
(
("license", "[license =&gt; AGPLv3]"),
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/AGPLv3">AGPLv3</a></div>',
'<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/AGPLv3">AGPLv3</a></div>', # noqa: W605
),
(
("ref", "[requires =&gt; RepoName]"),

View file

@ -293,7 +293,7 @@ def test_repo2db_mapper_adds_new_repos(request, backend):
for _repo in cleanup_repos:
del_result = RepoModel().delete(_repo, call_events=False)
Session().commit()
assert del_result is True
assert del_result.is_success is True
for _repo_group in cleanup_groups:
del_result = RepoGroupModel().delete(_repo_group, force_delete=True, call_events=False)

View file

@ -26,7 +26,7 @@ from rhodecode.lib.exceptions import AttachedForksError
from rhodecode.lib.utils import make_db_config
from rhodecode.model.db import Repository
from rhodecode.model.meta import Session
from rhodecode.model.repo import RepoModel
from rhodecode.model.repo import RepoModel, ForksAction
from rhodecode.model.scm import ScmModel
@ -42,14 +42,34 @@ class TestRepoModel(object):
assert Repository.get_by_repo_name(repo_name=backend.repo_name) is None
assert repo.repo_name not in repos
def test_remove_repo_raises_exc_when_attached_forks(self, backend):
def test_remove_repo_forks_are_detached(self, backend):
repo = backend.create_repo()
Session().commit()
backend.create_fork()
fork = backend.create_fork()
fork_name = fork.repo_name
Session().commit()
with pytest.raises(AttachedForksError):
RepoModel().delete(repo=repo)
assert fork.parent is not None
RepoModel().delete(repo=repo)
fork = Repository.get_by_repo_name(repo_name=fork_name)
assert fork.parent is None
def test_remove_repo_forks_are_deleted(self, backend):
repo = backend.create_repo()
repo.private = True
Session().commit()
fork = backend.create_fork()
fork_name = fork.repo_name
Session().commit()
assert fork.parent is not None
RepoModel().delete(repo=repo)
fork = Repository.get_by_repo_name(repo_name=fork_name)
assert fork is None
def test_remove_repo_delete_forks(self, backend):
repo = backend.create_repo()
@ -61,7 +81,7 @@ class TestRepoModel(object):
fork_of_fork = backend.create_fork()
Session().commit()
RepoModel().delete(repo=repo, forks="delete")
RepoModel().delete(repo=repo, forks_action=ForksAction.DELETE)
Session().commit()
assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None
@ -78,7 +98,7 @@ class TestRepoModel(object):
fork_of_fork = backend.create_fork()
Session().commit()
RepoModel().delete(repo=repo, forks="detach")
RepoModel().delete(repo=repo, forks_action=ForksAction.DETACH)
Session().commit()
assert Repository.get_by_repo_name(repo_name=repo.repo_name) is None