# Copyright (C) 2013-2024 RhodeCode GmbH # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License, version 3 # (only), as published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # # 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/ """ Set of hooks run by RhodeCode Enterprise """ import os import logging import rhodecode from rhodecode import events from rhodecode.lib import helpers as h from rhodecode.lib import audit_logger from rhodecode.lib.utils2 import safe_str, user_agent_normalizer from rhodecode.lib.exceptions import ( HTTPLockedRepo, HTTPBranchProtected, UserCreationError, ClientNotSupported) from rhodecode.model.db import Repository, User from rhodecode.lib.statsd_client import StatsdClient log = logging.getLogger(__name__) class HookResponse: def __init__(self, status, output): self.status = status self.output = output def __add__(self, other): other_status = getattr(other, 'status', 0) new_status = max(self.status, other_status) other_output = getattr(other, 'output', '') new_output = self.output + other_output return HookResponse(new_status, new_output) def __bool__(self): return self.status == 0 def to_json(self): return {'status': self.status, 'output': self.output} def __repr__(self): return self.to_json().__repr__() def is_shadow_repo(extras): """ Returns ``True`` if this is an action executed against a shadow repository. """ return extras['is_shadow_repo'] def check_vcs_client(extras): """ Checks if vcs client is allowed (Only works in enterprise edition) """ try: from rc_ee.lib.security.utils import is_vcs_client_whitelisted except ModuleNotFoundError: is_vcs_client_whitelisted = lambda *x: True backend = extras.get('scm') user_agent = extras.get('user_agent') if not is_vcs_client_whitelisted(user_agent, backend): raise ClientNotSupported(f"Your {backend} client (version={user_agent}) is forbidden by security rules") def check_locked_repo(extras, check_same_user=True): user = User.get_by_username(extras.username) output = '' if extras.locked_by[0] and (not check_same_user or user.user_id != extras.locked_by[0]): locked_by = User.get(extras.locked_by[0]).username reason = extras.locked_by[2] # this exception is interpreted in git/hg middlewares and based # on that proper return code is server to client _http_ret = HTTPLockedRepo(_locked_by_explanation(extras.repository, locked_by, reason)) if str(_http_ret.code).startswith('2'): # 2xx Codes don't raise exceptions output = _http_ret.title else: raise _http_ret return output def check_branch_protected(extras): if extras.commit_ids and extras.check_branch_perms: user = User.get_by_username(extras.username) auth_user = user.AuthUser() repo = Repository.get_by_repo_name(extras.repository) if not repo: raise ValueError(f'Repo for {extras.repository} not found') affected_branches = [] if repo.repo_type == 'hg': for entry in extras.commit_ids: if entry['type'] == 'branch': is_forced = bool(entry['multiple_heads']) affected_branches.append([entry['name'], is_forced]) elif repo.repo_type == 'git': for entry in extras.commit_ids: if entry['type'] == 'heads': is_forced = bool(entry['pruned_sha']) affected_branches.append([entry['name'], is_forced]) for branch_name, is_forced in affected_branches: rule, branch_perm = auth_user.get_rule_and_branch_permission(extras.repository, branch_name) if not branch_perm: # no branch permission found for this branch, just keep checking continue if branch_perm == 'branch.push_force': continue elif branch_perm == 'branch.push' and is_forced is False: continue elif branch_perm == 'branch.push' and is_forced is True: halt_message = f'Branch `{branch_name}` changes rejected by rule {rule}. ' \ f'FORCE PUSH FORBIDDEN.' else: halt_message = f'Branch `{branch_name}` changes rejected by rule {rule}.' if halt_message: _http_ret = HTTPBranchProtected(halt_message) raise _http_ret def _get_scm_size(alias, root_path): if not alias.startswith('.'): alias += '.' size_scm, size_root = 0, 0 for path, unused_dirs, files in os.walk(safe_str(root_path)): if path.find(alias) != -1: for f in files: try: size_scm += os.path.getsize(os.path.join(path, f)) except OSError: pass else: for f in files: try: size_root += os.path.getsize(os.path.join(path, f)) except OSError: pass size_scm_f = h.format_byte_size_binary(size_scm) size_root_f = h.format_byte_size_binary(size_root) size_total_f = h.format_byte_size_binary(size_root + size_scm) return size_scm_f, size_root_f, size_total_f # actual hooks called by Mercurial internally, and GIT by our Python Hooks def repo_size(extras): """Present size of repository after push.""" repo = Repository.get_by_repo_name(extras.repository) vcs_part = f'.{repo.repo_type}' size_vcs, size_root, size_total = _get_scm_size(vcs_part, repo.repo_full_path) msg = f'RhodeCode: `{repo.repo_name}` size summary {vcs_part}:{size_vcs} repo:{size_root} total:{size_total}\n' return HookResponse(0, msg) def pre_pull(extras): """ Hook executed before pulling the code. It bans pulling when the repository is locked. It bans pulling when incorrect client is used. """ output = '' check_vcs_client(extras) # locking repo can, but not have to stop the operation it can also just produce output output += check_locked_repo(extras, check_same_user=False) # Propagate to external components. This is done after checking the # lock, for consistent behavior. hook_response = '' if not is_shadow_repo(extras): extras.hook_type = extras.hook_type or 'pre_pull' hook_response = pre_pull_extension(repo_store_path=Repository.base_path(), **extras) events.trigger(events.RepoPrePullEvent(repo_name=extras.repository, extras=extras)) return HookResponse(0, output) + hook_response def post_pull(extras): """Hook executed after client pulls the code.""" audit_user = audit_logger.UserWrap( username=extras.username, ip_addr=extras.ip) repo = audit_logger.RepoWrap(repo_name=extras.repository) audit_logger.store( 'user.pull', action_data={'user_agent': extras.user_agent}, user=audit_user, repo=repo, commit=True) statsd = StatsdClient.statsd if statsd: statsd.incr('rhodecode_pull_total', tags=[ f'user-agent:{user_agent_normalizer(extras.user_agent)}', ]) output = '' # make lock is a tri state False, True, None. We only make lock on True if extras.make_lock is True and not is_shadow_repo(extras): user = User.get_by_username(extras.username) Repository.lock(Repository.get_by_repo_name(extras.repository), user.user_id, lock_reason=Repository.LOCK_PULL) msg = f'Made lock on repo `{extras.repository}`' output += msg # Propagate to external components. hook_response = '' if not is_shadow_repo(extras): extras.hook_type = extras.hook_type or 'post_pull' hook_response = post_pull_extension( repo_store_path=Repository.base_path(), **extras) events.trigger(events.RepoPullEvent( repo_name=extras.repository, extras=extras)) return HookResponse(0, output) + hook_response def pre_push(extras): """ Hook executed before pushing code. It bans pushing when the repository is locked. It banks pushing when incorrect client is used. It also checks for Branch protection """ output = '' check_vcs_client(extras) # locking repo can, but not have to stop the operation it can also just produce output output += check_locked_repo(extras) hook_response = '' if not is_shadow_repo(extras): check_branch_protected(extras) # Propagate to external components. This is done after checking the # lock, for consistent behavior. hook_response = pre_push_extension(repo_store_path=Repository.base_path(), **extras) events.trigger(events.RepoPrePushEvent(repo_name=extras.repository, extras=extras)) return HookResponse(0, output) + hook_response def post_push(extras): """Hook executed after user pushes to the repository.""" commit_ids = extras.commit_ids # log the push call audit_user = audit_logger.UserWrap( username=extras.username, ip_addr=extras.ip) repo = audit_logger.RepoWrap(repo_name=extras.repository) audit_logger.store( 'user.push', action_data={ 'user_agent': extras.user_agent, 'commit_ids': commit_ids[:400]}, user=audit_user, repo=repo, commit=True) statsd = StatsdClient.statsd if statsd: statsd.incr('rhodecode_push_total', tags=[ f'user-agent:{user_agent_normalizer(extras.user_agent)}', ]) # Propagate to external components. output = '' # make lock is a tri state False, True, None. We only release lock on False if extras.make_lock is False and not is_shadow_repo(extras): Repository.unlock(Repository.get_by_repo_name(extras.repository)) msg = f'Released lock on repo `{extras.repository}`\n' output += msg if extras.new_refs: tmpl = '{}/{}/pull-request/new?{{ref_type}}={{ref_name}}'.format( safe_str(extras.server_url), safe_str(extras.repository)) for branch_name in extras.new_refs['branches']: pr_link = tmpl.format(ref_type='branch', ref_name=safe_str(branch_name)) output += f'RhodeCode: open pull request link: {pr_link}\n' for book_name in extras.new_refs['bookmarks']: pr_link = tmpl.format(ref_type='bookmark', ref_name=safe_str(book_name)) output += f'RhodeCode: open pull request link: {pr_link}\n' hook_response = '' if not is_shadow_repo(extras): hook_response = post_push_extension(repo_store_path=Repository.base_path(), **extras) events.trigger(events.RepoPushEvent(repo_name=extras.repository, pushed_commit_ids=commit_ids, extras=extras)) output += 'RhodeCode: push completed\n' return HookResponse(0, output) + hook_response def _locked_by_explanation(repo_name, user_name, reason): message = f'Repository `{repo_name}` locked by user `{user_name}`. Reason:`{reason}`' return message def check_allowed_create_user(user_dict, created_by, **kwargs): # pre create hooks if pre_create_user.is_active(): hook_result = pre_create_user(created_by=created_by, **user_dict) allowed = hook_result.status == 0 if not allowed: reason = hook_result.output raise UserCreationError(reason) class ExtensionCallback(object): """ Forwards a given call to rcextensions, sanitizes keyword arguments. Does check if there is an extension active for that hook. If it is there, it will forward all `kwargs_keys` keyword arguments to the extension callback. """ def __init__(self, hook_name, kwargs_keys): self._hook_name = hook_name self._kwargs_keys = set(kwargs_keys) def __call__(self, *args, **kwargs): log.debug('Calling extension callback for `%s`', self._hook_name) callback = self._get_callback() if not callback: log.debug('extension callback `%s` not found, skipping...', self._hook_name) return kwargs_to_pass = {} for key in self._kwargs_keys: try: kwargs_to_pass[key] = kwargs[key] except KeyError: log.error('Failed to fetch %s key from given kwargs. ' 'Expected keys: %s', key, self._kwargs_keys) raise # backward compat for removed api_key for old hooks. This was it works # with older rcextensions that require api_key present if self._hook_name in ['CREATE_USER_HOOK', 'DELETE_USER_HOOK']: kwargs_to_pass['api_key'] = '_DEPRECATED_' result = callback(**kwargs_to_pass) log.debug('got rcextensions result: %s', result) return result def is_active(self): return hasattr(rhodecode.EXTENSIONS, self._hook_name) def _get_callback(self): if rhodecode.is_test: log.debug('In test mode, reloading rcextensions...') # NOTE: for test re-load rcextensions always so we can dynamically change them for testing purposes from rhodecode.lib.utils import load_rcextensions load_rcextensions(root_path=os.path.dirname(rhodecode.CONFIG['__file__'])) return getattr(rhodecode.EXTENSIONS, self._hook_name, None) return getattr(rhodecode.EXTENSIONS, self._hook_name, None) pre_pull_extension = ExtensionCallback( hook_name='PRE_PULL_HOOK', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'hook_type', 'user_agent', 'repo_store_path',)) post_pull_extension = ExtensionCallback( hook_name='PULL_HOOK', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'hook_type', 'user_agent', 'repo_store_path',)) pre_push_extension = ExtensionCallback( hook_name='PRE_PUSH_HOOK', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',)) post_push_extension = ExtensionCallback( hook_name='PUSH_HOOK', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',)) pre_create_user = ExtensionCallback( hook_name='PRE_CREATE_USER_HOOK', kwargs_keys=( 'username', 'password', 'email', 'firstname', 'lastname', 'active', 'admin', 'created_by')) create_pull_request = ExtensionCallback( hook_name='CREATE_PULL_REQUEST', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'pull_request_id', 'url', 'title', 'description', 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status', 'mergeable', 'source', 'target', 'author', 'reviewers')) merge_pull_request = ExtensionCallback( hook_name='MERGE_PULL_REQUEST', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'pull_request_id', 'url', 'title', 'description', 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status', 'mergeable', 'source', 'target', 'author', 'reviewers')) close_pull_request = ExtensionCallback( hook_name='CLOSE_PULL_REQUEST', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'pull_request_id', 'url', 'title', 'description', 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status', 'mergeable', 'source', 'target', 'author', 'reviewers')) review_pull_request = ExtensionCallback( hook_name='REVIEW_PULL_REQUEST', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'pull_request_id', 'url', 'title', 'description', 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status', 'mergeable', 'source', 'target', 'author', 'reviewers')) comment_pull_request = ExtensionCallback( hook_name='COMMENT_PULL_REQUEST', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'pull_request_id', 'url', 'title', 'description', 'status', 'comment', 'created_on', 'updated_on', 'commit_ids', 'review_status', 'mergeable', 'source', 'target', 'author', 'reviewers')) comment_edit_pull_request = ExtensionCallback( hook_name='COMMENT_EDIT_PULL_REQUEST', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'pull_request_id', 'url', 'title', 'description', 'status', 'comment', 'created_on', 'updated_on', 'commit_ids', 'review_status', 'mergeable', 'source', 'target', 'author', 'reviewers')) update_pull_request = ExtensionCallback( hook_name='UPDATE_PULL_REQUEST', kwargs_keys=( 'server_url', 'config', 'scm', 'username', 'ip', 'action', 'repository', 'pull_request_id', 'url', 'title', 'description', 'status', 'created_on', 'updated_on', 'commit_ids', 'review_status', 'mergeable', 'source', 'target', 'author', 'reviewers')) create_user = ExtensionCallback( hook_name='CREATE_USER_HOOK', kwargs_keys=( 'username', 'full_name_or_username', 'full_contact', 'user_id', 'name', 'firstname', 'short_contact', 'admin', 'lastname', 'ip_addresses', 'extern_type', 'extern_name', 'email', 'api_keys', 'last_login', 'full_name', 'active', 'password', 'emails', 'inherit_default_permissions', 'created_by', 'created_on')) delete_user = ExtensionCallback( hook_name='DELETE_USER_HOOK', kwargs_keys=( 'username', 'full_name_or_username', 'full_contact', 'user_id', 'name', 'firstname', 'short_contact', 'admin', 'lastname', 'ip_addresses', 'email', 'last_login', 'full_name', 'active', 'password', 'emails', 'inherit_default_permissions', 'deleted_by')) create_repository = ExtensionCallback( hook_name='CREATE_REPO_HOOK', kwargs_keys=( 'repo_name', 'repo_type', 'description', 'private', 'created_on', 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics', 'clone_uri', 'fork_id', 'group_id', 'created_by')) delete_repository = ExtensionCallback( hook_name='DELETE_REPO_HOOK', kwargs_keys=( 'repo_name', 'repo_type', 'description', 'private', 'created_on', 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics', 'clone_uri', 'fork_id', 'group_id', 'deleted_by', 'deleted_on')) comment_commit_repository = ExtensionCallback( hook_name='COMMENT_COMMIT_REPO_HOOK', kwargs_keys=( 'repo_name', 'repo_type', 'description', 'private', 'created_on', 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics', 'clone_uri', 'fork_id', 'group_id', 'repository', 'created_by', 'comment', 'commit')) comment_edit_commit_repository = ExtensionCallback( hook_name='COMMENT_EDIT_COMMIT_REPO_HOOK', kwargs_keys=( 'repo_name', 'repo_type', 'description', 'private', 'created_on', 'enable_downloads', 'repo_id', 'user_id', 'enable_statistics', 'clone_uri', 'fork_id', 'group_id', 'repository', 'created_by', 'comment', 'commit')) create_repository_group = ExtensionCallback( hook_name='CREATE_REPO_GROUP_HOOK', kwargs_keys=( 'group_name', 'group_parent_id', 'group_description', 'group_id', 'user_id', 'created_by', 'created_on', 'enable_locking'))