hooks_base.py
542 lines
| 20.0 KiB
| text/x-python
|
PythonLexer
r5608 | # Copyright (C) 2013-2024 RhodeCode GmbH | |||
r1 | # | |||
# 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 <http://www.gnu.org/licenses/>. | ||||
# | ||||
# 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 | ||||
r1455 | import logging | |||
r1 | ||||
import rhodecode | ||||
r375 | from rhodecode import events | |||
r1 | from rhodecode.lib import helpers as h | |||
r1736 | from rhodecode.lib import audit_logger | |||
r4858 | from rhodecode.lib.utils2 import safe_str, user_agent_normalizer | |||
r2979 | from rhodecode.lib.exceptions import ( | |||
r5607 | HTTPLockedRepo, HTTPBranchProtected, UserCreationError, ClientNotSupported) | |||
r1 | from rhodecode.model.db import Repository, User | |||
r4796 | from rhodecode.lib.statsd_client import StatsdClient | |||
r1 | ||||
r1455 | log = logging.getLogger(__name__) | |||
r1 | ||||
r5607 | class HookResponse: | |||
r3133 | 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 | ||||
r1 | ||||
r5298 | def to_json(self): | |||
return {'status': self.status, 'output': self.output} | ||||
r5607 | def __repr__(self): | |||
return self.to_json().__repr__() | ||||
r1 | ||||
Martin Bornhold
|
r900 | def is_shadow_repo(extras): | ||
""" | ||||
Returns ``True`` if this is an action executed against a shadow repository. | ||||
""" | ||||
return extras['is_shadow_repo'] | ||||
r5522 | 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') | ||||
r5607 | 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 | ||||
r5522 | ||||
r1 | 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) | ||||
r5081 | vcs_part = f'.{repo.repo_type}' | |||
size_vcs, size_root, size_total = _get_scm_size(vcs_part, repo.repo_full_path) | ||||
r5607 | msg = f'RhodeCode: `{repo.repo_name}` size summary {vcs_part}:{size_vcs} repo:{size_root} total:{size_total}\n' | |||
r1 | return HookResponse(0, msg) | |||
def pre_pull(extras): | ||||
""" | ||||
Hook executed before pulling the code. | ||||
It bans pulling when the repository is locked. | ||||
r5607 | It bans pulling when incorrect client is used. | |||
r1 | """ | |||
output = '' | ||||
r5607 | 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) | ||||
r1 | ||||
Martin Bornhold
|
r900 | # Propagate to external components. This is done after checking the | ||
# lock, for consistent behavior. | ||||
r3133 | hook_response = '' | |||
Martin Bornhold
|
r900 | if not is_shadow_repo(extras): | ||
r3133 | extras.hook_type = extras.hook_type or 'pre_pull' | |||
r5607 | hook_response = pre_pull_extension(repo_store_path=Repository.base_path(), **extras) | |||
events.trigger(events.RepoPrePullEvent(repo_name=extras.repository, extras=extras)) | ||||
r1 | ||||
r3133 | return HookResponse(0, output) + hook_response | |||
r1 | ||||
def post_pull(extras): | ||||
"""Hook executed after client pulls the code.""" | ||||
r1736 | audit_user = audit_logger.UserWrap( | |||
username=extras.username, | ||||
ip_addr=extras.ip) | ||||
r1737 | repo = audit_logger.RepoWrap(repo_name=extras.repository) | |||
r1736 | audit_logger.store( | |||
r3133 | 'user.pull', action_data={'user_agent': extras.user_agent}, | |||
r1737 | user=audit_user, repo=repo, commit=True) | |||
r1736 | ||||
r4796 | statsd = StatsdClient.statsd | |||
if statsd: | ||||
r4858 | statsd.incr('rhodecode_pull_total', tags=[ | |||
r5095 | f'user-agent:{user_agent_normalizer(extras.user_agent)}', | |||
r4858 | ]) | |||
r5607 | ||||
r1 | output = '' | |||
# make lock is a tri state False, True, None. We only make lock on True | ||||
Martin Bornhold
|
r900 | if extras.make_lock is True and not is_shadow_repo(extras): | ||
r1754 | user = User.get_by_username(extras.username) | |||
r1 | Repository.lock(Repository.get_by_repo_name(extras.repository), | |||
user.user_id, | ||||
lock_reason=Repository.LOCK_PULL) | ||||
r5607 | msg = f'Made lock on repo `{extras.repository}`' | |||
r1 | output += msg | |||
r3133 | # 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 | ||||
r1 | ||||
r5607 | 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 | ||||
r1 | def post_push(extras): | |||
"""Hook executed after user pushes to the repository.""" | ||||
r1754 | commit_ids = extras.commit_ids | |||
r1 | ||||
r1754 | # log the push call | |||
r1736 | audit_user = audit_logger.UserWrap( | |||
r1754 | username=extras.username, ip_addr=extras.ip) | |||
r1736 | repo = audit_logger.RepoWrap(repo_name=extras.repository) | |||
audit_logger.store( | ||||
r1829 | 'user.push', action_data={ | |||
r1736 | 'user_agent': extras.user_agent, | |||
r1964 | 'commit_ids': commit_ids[:400]}, | |||
r1736 | user=audit_user, repo=repo, commit=True) | |||
r4796 | statsd = StatsdClient.statsd | |||
if statsd: | ||||
r4858 | statsd.incr('rhodecode_push_total', tags=[ | |||
r5095 | f'user-agent:{user_agent_normalizer(extras.user_agent)}', | |||
r4858 | ]) | |||
r4796 | ||||
Martin Bornhold
|
r900 | # Propagate to external components. | ||
r1 | output = '' | |||
r5607 | ||||
r1 | # make lock is a tri state False, True, None. We only release lock on False | |||
Martin Bornhold
|
r900 | if extras.make_lock is False and not is_shadow_repo(extras): | ||
r1 | Repository.unlock(Repository.get_by_repo_name(extras.repository)) | |||
r5081 | msg = f'Released lock on repo `{extras.repository}`\n' | |||
r1 | output += msg | |||
r1755 | if extras.new_refs: | |||
r3503 | tmpl = '{}/{}/pull-request/new?{{ref_type}}={{ref_name}}'.format( | |||
safe_str(extras.server_url), safe_str(extras.repository)) | ||||
r3331 | ||||
r1755 | for branch_name in extras.new_refs['branches']: | |||
r5081 | pr_link = tmpl.format(ref_type='branch', ref_name=safe_str(branch_name)) | |||
output += f'RhodeCode: open pull request link: {pr_link}\n' | ||||
r1755 | ||||
for book_name in extras.new_refs['bookmarks']: | ||||
r5081 | pr_link = tmpl.format(ref_type='bookmark', ref_name=safe_str(book_name)) | |||
output += f'RhodeCode: open pull request link: {pr_link}\n' | ||||
r1755 | ||||
r3133 | hook_response = '' | |||
if not is_shadow_repo(extras): | ||||
r5607 | 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)) | ||||
r3133 | ||||
r1 | output += 'RhodeCode: push completed\n' | |||
r3133 | return HookResponse(0, output) + hook_response | |||
r1 | ||||
def _locked_by_explanation(repo_name, user_name, reason): | ||||
r5081 | message = f'Repository `{repo_name}` locked by user `{user_name}`. Reason:`{reason}`' | |||
r1 | return message | |||
def check_allowed_create_user(user_dict, created_by, **kwargs): | ||||
# pre create hooks | ||||
if pre_create_user.is_active(): | ||||
r3133 | hook_result = pre_create_user(created_by=created_by, **user_dict) | |||
allowed = hook_result.status == 0 | ||||
r1 | if not allowed: | |||
r3133 | reason = hook_result.output | |||
r1 | 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): | ||||
r3200 | 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 | ||||
r3133 | kwargs_to_pass = {} | |||
for key in self._kwargs_keys: | ||||
try: | ||||
kwargs_to_pass[key] = kwargs[key] | ||||
except KeyError: | ||||
r4305 | log.error('Failed to fetch %s key from given kwargs. ' | |||
'Expected keys: %s', key, self._kwargs_keys) | ||||
r3133 | raise | |||
r1455 | ||||
r3200 | # backward compat for removed api_key for old hooks. This was it works | |||
r1482 | # 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_' | ||||
r5607 | result = callback(**kwargs_to_pass) | |||
log.debug('got rcextensions result: %s', result) | ||||
return result | ||||
r1 | ||||
def is_active(self): | ||||
return hasattr(rhodecode.EXTENSIONS, self._hook_name) | ||||
def _get_callback(self): | ||||
r5607 | 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) | ||||
r1 | 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', | ||||
r3133 | 'repository', 'hook_type', 'user_agent', 'repo_store_path',)) | |||
r1 | ||||
post_pull_extension = ExtensionCallback( | ||||
hook_name='PULL_HOOK', | ||||
kwargs_keys=( | ||||
'server_url', 'config', 'scm', 'username', 'ip', 'action', | ||||
r3133 | 'repository', 'hook_type', 'user_agent', 'repo_store_path',)) | |||
r1 | ||||
pre_push_extension = ExtensionCallback( | ||||
hook_name='PRE_PUSH_HOOK', | ||||
kwargs_keys=( | ||||
'server_url', 'config', 'scm', 'username', 'ip', 'action', | ||||
r3133 | 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',)) | |||
r1 | ||||
post_push_extension = ExtensionCallback( | ||||
hook_name='PUSH_HOOK', | ||||
kwargs_keys=( | ||||
'server_url', 'config', 'scm', 'username', 'ip', 'action', | ||||
r3133 | 'repository', 'repo_store_path', 'commit_ids', 'hook_type', 'user_agent',)) | |||
r1 | ||||
pre_create_user = ExtensionCallback( | ||||
hook_name='PRE_CREATE_USER_HOOK', | ||||
kwargs_keys=( | ||||
'username', 'password', 'email', 'firstname', 'lastname', 'active', | ||||
'admin', 'created_by')) | ||||
r4445 | create_pull_request = ExtensionCallback( | |||
r1 | 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')) | ||||
r4445 | merge_pull_request = ExtensionCallback( | |||
r1 | 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')) | ||||
r4445 | close_pull_request = ExtensionCallback( | |||
r1 | 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')) | ||||
r4445 | review_pull_request = ExtensionCallback( | |||
r1 | 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')) | ||||
r4445 | comment_pull_request = ExtensionCallback( | |||
r4305 | 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')) | ||||
r4445 | 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( | ||||
r1 | 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')) | ||||
r4445 | create_user = ExtensionCallback( | |||
r1 | 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', | ||||
r1482 | 'email', 'api_keys', 'last_login', | |||
r1 | 'full_name', 'active', 'password', 'emails', | |||
'inherit_default_permissions', 'created_by', 'created_on')) | ||||
r4445 | delete_user = ExtensionCallback( | |||
r1 | hook_name='DELETE_USER_HOOK', | |||
kwargs_keys=( | ||||
'username', 'full_name_or_username', 'full_contact', 'user_id', | ||||
'name', 'firstname', 'short_contact', 'admin', 'lastname', | ||||
'ip_addresses', | ||||
r1482 | 'email', 'last_login', | |||
r1 | 'full_name', 'active', 'password', 'emails', | |||
'inherit_default_permissions', 'deleted_by')) | ||||
r4445 | create_repository = ExtensionCallback( | |||
r1 | 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')) | ||||
r4445 | delete_repository = ExtensionCallback( | |||
r1 | 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')) | ||||
r4445 | comment_commit_repository = ExtensionCallback( | |||
r4305 | 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')) | ||||
r4445 | 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')) | ||||
r4305 | ||||
r4445 | ||||
create_repository_group = ExtensionCallback( | ||||
r1 | 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')) | ||||