# HG changeset patch # User Marcin Kuzminski # Date 2016-11-24 22:48:10 # Node ID 0f1be4aa616fcec9908bd637069ad68d3ab01abb # Parent 2e863d1579f6003539f73ed9c8b5124c47eabb10 repo-schemas: refactor repository schemas and use it in API update/create functions. - now it uses consistent way of serializing/validating data - parent groups are consistenty handled by name parameter - fixes #4133 - fixes other problems with bad data - changes API slightly - validation chain ordering, first permissions, then uniqness. Helps prevent resource disvovery diff --git a/rhodecode/api/tests/test_create_repo.py b/rhodecode/api/tests/test_create_repo.py --- a/rhodecode/api/tests/test_create_repo.py +++ b/rhodecode/api/tests/test_create_repo.py @@ -23,8 +23,11 @@ import json import mock import pytest +from rhodecode.lib.utils2 import safe_unicode from rhodecode.lib.vcs import settings +from rhodecode.model.meta import Session from rhodecode.model.repo import RepoModel +from rhodecode.model.user import UserModel from rhodecode.tests import TEST_USER_ADMIN_LOGIN from rhodecode.api.tests.utils import ( build_data, api_call, assert_ok, assert_error, crash) @@ -36,29 +39,37 @@ fixture = Fixture() @pytest.mark.usefixtures("testuser_api", "app") class TestCreateRepo(object): - def test_api_create_repo(self, backend): - repo_name = 'api-repo-1' + + @pytest.mark.parametrize('given, expected_name, expected_exc', [ + ('api repo-1', 'api-repo-1', False), + ('api-repo 1-ąć', 'api-repo-1-ąć', False), + (u'unicode-ąć', u'unicode-ąć', False), + ('some repo v1.2', 'some-repo-v1.2', False), + ('v2.0', 'v2.0', False), + ]) + def test_api_create_repo(self, backend, given, expected_name, expected_exc): + id_, params = build_data( self.apikey, 'create_repo', - repo_name=repo_name, + repo_name=given, owner=TEST_USER_ADMIN_LOGIN, repo_type=backend.alias, ) response = api_call(self.app, params) - repo = RepoModel().get_by_repo_name(repo_name) - - assert repo is not None ret = { - 'msg': 'Created new repository `%s`' % (repo_name,), + 'msg': 'Created new repository `%s`' % (expected_name,), 'success': True, 'task': None, } expected = ret assert_ok(id_, expected, given=response.body) - id_, params = build_data(self.apikey, 'get_repo', repoid=repo_name) + repo = RepoModel().get_by_repo_name(safe_unicode(expected_name)) + assert repo is not None + + id_, params = build_data(self.apikey, 'get_repo', repoid=expected_name) response = api_call(self.app, params) body = json.loads(response.body) @@ -66,7 +77,7 @@ class TestCreateRepo(object): assert body['result']['enable_locking'] is False assert body['result']['enable_statistics'] is False - fixture.destroy_repo(repo_name) + fixture.destroy_repo(safe_unicode(expected_name)) def test_api_create_restricted_repo_type(self, backend): repo_name = 'api-repo-type-{0}'.format(backend.alias) @@ -158,6 +169,21 @@ class TestCreateRepo(object): fixture.destroy_repo(repo_name) fixture.destroy_repo_group(repo_group_name) + def test_create_repo_in_group_that_doesnt_exist(self, backend, user_util): + repo_group_name = 'fake_group' + + repo_name = '%s/api-repo-gr' % (repo_group_name,) + id_, params = build_data( + self.apikey, 'create_repo', + repo_name=repo_name, + owner=TEST_USER_ADMIN_LOGIN, + repo_type=backend.alias,) + response = api_call(self.app, params) + + expected = {'repo_group': 'Repository group `{}` does not exist'.format( + repo_group_name)} + assert_error(id_, expected, given=response.body) + def test_api_create_repo_unknown_owner(self, backend): repo_name = 'api-repo-2' owner = 'i-dont-exist' @@ -218,10 +244,48 @@ class TestCreateRepo(object): owner=owner) response = api_call(self.app, params) - expected = 'Only RhodeCode admin can specify `owner` param' + expected = 'Only RhodeCode super-admin can specify `owner` param' assert_error(id_, expected, given=response.body) fixture.destroy_repo(repo_name) + def test_api_create_repo_by_non_admin_no_parent_group_perms(self, backend): + repo_group_name = 'no-access' + fixture.create_repo_group(repo_group_name) + repo_name = 'no-access/api-repo' + + id_, params = build_data( + self.apikey_regular, 'create_repo', + repo_name=repo_name, + repo_type=backend.alias) + response = api_call(self.app, params) + + expected = {'repo_group': 'Repository group `{}` does not exist'.format( + repo_group_name)} + assert_error(id_, expected, given=response.body) + fixture.destroy_repo_group(repo_group_name) + fixture.destroy_repo(repo_name) + + def test_api_create_repo_non_admin_no_permission_to_create_to_root_level( + self, backend, user_util): + + regular_user = user_util.create_user() + regular_user_api_key = regular_user.api_key + + usr = UserModel().get_by_username(regular_user.username) + usr.inherit_default_permissions = False + Session().add(usr) + + repo_name = backend.new_repo_name() + id_, params = build_data( + regular_user_api_key, 'create_repo', + repo_name=repo_name, + repo_type=backend.alias) + response = api_call(self.app, params) + expected = { + "repo_name": "You do not have the permission to " + "store repositories in the root location."} + assert_error(id_, expected, given=response.body) + def test_api_create_repo_exists(self, backend): repo_name = backend.repo_name id_, params = build_data( @@ -230,7 +294,9 @@ class TestCreateRepo(object): owner=TEST_USER_ADMIN_LOGIN, repo_type=backend.alias,) response = api_call(self.app, params) - expected = "repo `%s` already exist" % (repo_name,) + expected = { + 'unique_repo_name': 'Repository with name `{}` already exists'.format( + repo_name)} assert_error(id_, expected, given=response.body) @mock.patch.object(RepoModel, 'create', crash) @@ -245,26 +311,40 @@ class TestCreateRepo(object): expected = 'failed to create repository `%s`' % (repo_name,) assert_error(id_, expected, given=response.body) - def test_create_repo_with_extra_slashes_in_name(self, backend, user_util): - existing_repo_group = user_util.create_repo_group() - dirty_repo_name = '//{}/repo_name//'.format( - existing_repo_group.group_name) - cleaned_repo_name = '{}/repo_name'.format( - existing_repo_group.group_name) + @pytest.mark.parametrize('parent_group, dirty_name, expected_name', [ + (None, 'foo bar x', 'foo-bar-x'), + ('foo', '/foo//bar x', 'foo/bar-x'), + ('foo-bar', 'foo-bar //bar x', 'foo-bar/bar-x'), + ]) + def test_create_repo_with_extra_slashes_in_name( + self, backend, parent_group, dirty_name, expected_name): + + if parent_group: + gr = fixture.create_repo_group(parent_group) + assert gr.group_name == parent_group id_, params = build_data( self.apikey, 'create_repo', - repo_name=dirty_repo_name, + repo_name=dirty_name, repo_type=backend.alias, owner=TEST_USER_ADMIN_LOGIN,) response = api_call(self.app, params) - repo = RepoModel().get_by_repo_name(cleaned_repo_name) + expected ={ + "msg": "Created new repository `{}`".format(expected_name), + "task": None, + "success": True + } + assert_ok(id_, expected, response.body) + + repo = RepoModel().get_by_repo_name(expected_name) assert repo is not None expected = { - 'msg': 'Created new repository `%s`' % (cleaned_repo_name,), + 'msg': 'Created new repository `%s`' % (expected_name,), 'success': True, 'task': None, } assert_ok(id_, expected, given=response.body) - fixture.destroy_repo(cleaned_repo_name) + fixture.destroy_repo(expected_name) + if parent_group: + fixture.destroy_repo_group(parent_group) diff --git a/rhodecode/api/tests/test_fork_repo.py b/rhodecode/api/tests/test_fork_repo.py --- a/rhodecode/api/tests/test_fork_repo.py +++ b/rhodecode/api/tests/test_fork_repo.py @@ -24,6 +24,7 @@ import pytest from rhodecode.model.meta import Session from rhodecode.model.repo import RepoModel +from rhodecode.model.repo_group import RepoGroupModel from rhodecode.model.user import UserModel from rhodecode.tests import TEST_USER_ADMIN_LOGIN from rhodecode.api.tests.utils import ( @@ -99,11 +100,35 @@ class TestApiForkRepo(object): finally: fixture.destroy_repo(fork_name) + def test_api_fork_repo_non_admin_into_group_no_permission(self, backend, user_util): + source_name = backend['minimal'].repo_name + repo_group = user_util.create_repo_group() + repo_group_name = repo_group.group_name + fork_name = '%s/api-repo-fork' % repo_group_name + + id_, params = build_data( + self.apikey_regular, 'fork_repo', + repoid=source_name, + fork_name=fork_name) + response = api_call(self.app, params) + + expected = { + 'repo_group': 'Repository group `{}` does not exist'.format( + repo_group_name)} + try: + assert_error(id_, expected, given=response.body) + finally: + fixture.destroy_repo(fork_name) + def test_api_fork_repo_non_admin_into_group(self, backend, user_util): source_name = backend['minimal'].repo_name repo_group = user_util.create_repo_group() fork_name = '%s/api-repo-fork' % repo_group.group_name + RepoGroupModel().grant_user_permission( + repo_group, self.TEST_USER_LOGIN, 'group.admin') + Session().commit() + id_, params = build_data( self.apikey_regular, 'fork_repo', repoid=source_name, @@ -129,10 +154,11 @@ class TestApiForkRepo(object): fork_name=fork_name, owner=TEST_USER_ADMIN_LOGIN) response = api_call(self.app, params) - expected = 'Only RhodeCode admin can specify `owner` param' + expected = 'Only RhodeCode super-admin can specify `owner` param' assert_error(id_, expected, given=response.body) - def test_api_fork_repo_non_admin_no_permission_to_fork(self, backend): + def test_api_fork_repo_non_admin_no_permission_of_source_repo( + self, backend): source_name = backend['minimal'].repo_name RepoModel().grant_user_permission(repo=source_name, user=self.TEST_USER_LOGIN, @@ -147,19 +173,44 @@ class TestApiForkRepo(object): assert_error(id_, expected, given=response.body) def test_api_fork_repo_non_admin_no_permission_to_fork_to_root_level( - self, backend): + self, backend, user_util): + + regular_user = user_util.create_user() + regular_user_api_key = regular_user.api_key + usr = UserModel().get_by_username(regular_user.username) + usr.inherit_default_permissions = False + Session().add(usr) + UserModel().grant_perm(regular_user.username, 'hg.fork.repository') + source_name = backend['minimal'].repo_name + fork_name = backend.new_repo_name() + id_, params = build_data( + regular_user_api_key, 'fork_repo', + repoid=source_name, + fork_name=fork_name) + response = api_call(self.app, params) + expected = { + "repo_name": "You do not have the permission to " + "store repositories in the root location."} + assert_error(id_, expected, given=response.body) - usr = UserModel().get_by_username(self.TEST_USER_LOGIN) + def test_api_fork_repo_non_admin_no_permission_to_fork( + self, backend, user_util): + + regular_user = user_util.create_user() + regular_user_api_key = regular_user.api_key + usr = UserModel().get_by_username(regular_user.username) usr.inherit_default_permissions = False Session().add(usr) + source_name = backend['minimal'].repo_name fork_name = backend.new_repo_name() id_, params = build_data( - self.apikey_regular, 'fork_repo', + regular_user_api_key, 'fork_repo', repoid=source_name, fork_name=fork_name) response = api_call(self.app, params) + expected = "Access was denied to this resource." assert_error(id_, expected, given=response.body) @@ -189,7 +240,9 @@ class TestApiForkRepo(object): response = api_call(self.app, params) try: - expected = "fork `%s` already exist" % (fork_name,) + expected = { + 'unique_repo_name': 'Repository with name `{}` already exists'.format( + fork_name)} assert_error(id_, expected, given=response.body) finally: fixture.destroy_repo(fork_repo.repo_name) @@ -205,7 +258,9 @@ class TestApiForkRepo(object): owner=TEST_USER_ADMIN_LOGIN) response = api_call(self.app, params) - expected = "repo `%s` already exist" % (fork_name,) + expected = { + 'unique_repo_name': 'Repository with name `{}` already exists'.format( + fork_name)} assert_error(id_, expected, given=response.body) @mock.patch.object(RepoModel, 'create_fork', crash) diff --git a/rhodecode/api/tests/test_update_repo.py b/rhodecode/api/tests/test_update_repo.py --- a/rhodecode/api/tests/test_update_repo.py +++ b/rhodecode/api/tests/test_update_repo.py @@ -32,35 +32,60 @@ fixture = Fixture() UPDATE_REPO_NAME = 'api_update_me' -class SAME_AS_UPDATES(object): """ Constant used for tests below """ + +class SAME_AS_UPDATES(object): + """ Constant used for tests below """ + @pytest.mark.usefixtures("testuser_api", "app") class TestApiUpdateRepo(object): @pytest.mark.parametrize("updates, expected", [ - ({'owner': TEST_USER_REGULAR_LOGIN}, SAME_AS_UPDATES), - ({'description': 'new description'}, SAME_AS_UPDATES), - ({'clone_uri': 'http://foo.com/repo'}, SAME_AS_UPDATES), - ({'clone_uri': None}, {'clone_uri': ''}), - ({'clone_uri': ''}, {'clone_uri': ''}), - ({'landing_rev': 'branch:master'}, {'landing_rev': ['branch','master']}), - ({'enable_statistics': True}, SAME_AS_UPDATES), - ({'enable_locking': True}, SAME_AS_UPDATES), - ({'enable_downloads': True}, SAME_AS_UPDATES), - ({'name': 'new_repo_name'}, { + ({'owner': TEST_USER_REGULAR_LOGIN}, + SAME_AS_UPDATES), + + ({'description': 'new description'}, + SAME_AS_UPDATES), + + ({'clone_uri': 'http://foo.com/repo'}, + SAME_AS_UPDATES), + + ({'clone_uri': None}, + {'clone_uri': ''}), + + ({'clone_uri': ''}, + {'clone_uri': ''}), + + ({'landing_rev': 'rev:tip'}, + {'landing_rev': ['rev', 'tip']}), + + ({'enable_statistics': True}, + SAME_AS_UPDATES), + + ({'enable_locking': True}, + SAME_AS_UPDATES), + + ({'enable_downloads': True}, + SAME_AS_UPDATES), + + ({'repo_name': 'new_repo_name'}, + { 'repo_name': 'new_repo_name', - 'url': 'http://test.example.com:80/new_repo_name', - }), - ({'group': 'test_group_for_update'}, { - 'repo_name': 'test_group_for_update/%s' % UPDATE_REPO_NAME, - 'url': 'http://test.example.com:80/test_group_for_update/%s' % UPDATE_REPO_NAME - }), + 'url': 'http://test.example.com:80/new_repo_name' + }), + + ({'repo_name': 'test_group_for_update/{}'.format(UPDATE_REPO_NAME), + '_group': 'test_group_for_update'}, + { + 'repo_name': 'test_group_for_update/{}'.format(UPDATE_REPO_NAME), + 'url': 'http://test.example.com:80/test_group_for_update/{}'.format(UPDATE_REPO_NAME) + }), ]) def test_api_update_repo(self, updates, expected, backend): repo_name = UPDATE_REPO_NAME repo = fixture.create_repo(repo_name, repo_type=backend.alias) - if updates.get('group'): - fixture.create_repo_group(updates['group']) + if updates.get('_group'): + fixture.create_repo_group(updates['_group']) expected_api_data = repo.get_api_data(include_secrets=True) if expected is SAME_AS_UPDATES: @@ -68,15 +93,12 @@ class TestApiUpdateRepo(object): else: expected_api_data.update(expected) - id_, params = build_data( self.apikey, 'update_repo', repoid=repo_name, **updates) response = api_call(self.app, params) - if updates.get('name'): - repo_name = updates['name'] - if updates.get('group'): - repo_name = '/'.join([updates['group'], repo_name]) + if updates.get('repo_name'): + repo_name = updates['repo_name'] try: expected = { @@ -86,8 +108,8 @@ class TestApiUpdateRepo(object): assert_ok(id_, expected, given=response.body) finally: fixture.destroy_repo(repo_name) - if updates.get('group'): - fixture.destroy_repo_group(updates['group']) + if updates.get('_group'): + fixture.destroy_repo_group(updates['_group']) def test_api_update_repo_fork_of_field(self, backend): master_repo = backend.create_repo() @@ -118,19 +140,23 @@ class TestApiUpdateRepo(object): id_, params = build_data( self.apikey, 'update_repo', repoid=repo.repo_name, **updates) response = api_call(self.app, params) - expected = 'repository `{}` does not exist'.format(master_repo_name) + expected = { + 'repo_fork_of': 'Fork with id `{}` does not exists'.format( + master_repo_name)} assert_error(id_, expected, given=response.body) def test_api_update_repo_with_repo_group_not_existing(self): repo_name = 'admin_owned' + fake_repo_group = 'test_group_for_update' fixture.create_repo(repo_name) - updates = {'group': 'test_group_for_update'} + updates = {'repo_name': '{}/{}'.format(fake_repo_group, repo_name)} id_, params = build_data( self.apikey, 'update_repo', repoid=repo_name, **updates) response = api_call(self.app, params) try: - expected = 'repository group `%s` does not exist' % ( - updates['group'],) + expected = { + 'repo_group': 'Repository group `{}` does not exist'.format(fake_repo_group) + } assert_error(id_, expected, given=response.body) finally: fixture.destroy_repo(repo_name) diff --git a/rhodecode/api/views/repo_api.py b/rhodecode/api/views/repo_api.py --- a/rhodecode/api/views/repo_api.py +++ b/rhodecode/api/views/repo_api.py @@ -21,29 +21,26 @@ import logging import time -import colander - -from rhodecode import BACKENDS -from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCForbidden, json +import rhodecode +from rhodecode.api import ( + jsonrpc_method, JSONRPCError, JSONRPCForbidden, JSONRPCValidationError) from rhodecode.api.utils import ( has_superadmin_permission, Optional, OAttr, get_repo_or_error, - get_user_group_or_error, get_user_or_error, has_repo_permissions, - get_perm_or_error, store_update, get_repo_group_or_error, parse_args, - get_origin, build_commit_data) -from rhodecode.lib.auth import ( - HasPermissionAnyApi, HasRepoGroupPermissionAnyApi, - HasUserGroupPermissionAnyApi) + get_user_group_or_error, get_user_or_error, validate_repo_permissions, + get_perm_or_error, parse_args, get_origin, build_commit_data, + validate_set_owner_permissions) +from rhodecode.lib.auth import HasPermissionAnyApi, HasUserGroupPermissionAnyApi from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError -from rhodecode.lib.utils import map_groups from rhodecode.lib.utils2 import str2bool, time_to_datetime +from rhodecode.lib.ext_json import json from rhodecode.model.changeset_status import ChangesetStatusModel from rhodecode.model.comment import ChangesetCommentsModel from rhodecode.model.db import ( Session, ChangesetStatus, RepositoryField, Repository) from rhodecode.model.repo import RepoModel -from rhodecode.model.repo_group import RepoGroupModel from rhodecode.model.scm import ScmModel, RepoList from rhodecode.model.settings import SettingsModel, VcsSettingsModel +from rhodecode.model import validation_schema from rhodecode.model.validation_schema.schemas import repo_schema log = logging.getLogger(__name__) @@ -177,6 +174,7 @@ def get_repo(request, apiuser, repoid, c repo = get_repo_or_error(repoid) cache = Optional.extract(cache) + include_secrets = False if has_superadmin_permission(apiuser): include_secrets = True @@ -184,7 +182,7 @@ def get_repo(request, apiuser, repoid, c # check if we have at least read permission for this repo ! _perms = ( 'repository.admin', 'repository.write', 'repository.read',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) permissions = [] for _user in repo.permissions(): @@ -292,7 +290,7 @@ def get_repo_changeset(request, apiuser, if not has_superadmin_permission(apiuser): _perms = ( 'repository.admin', 'repository.write', 'repository.read',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) changes_details = Optional.extract(details) _changes_details_types = ['basic', 'extended', 'full'] @@ -355,7 +353,7 @@ def get_repo_changesets(request, apiuser if not has_superadmin_permission(apiuser): _perms = ( 'repository.admin', 'repository.write', 'repository.read',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) changes_details = Optional.extract(details) _changes_details_types = ['basic', 'extended', 'full'] @@ -450,7 +448,7 @@ def get_repo_nodes(request, apiuser, rep if not has_superadmin_permission(apiuser): _perms = ( 'repository.admin', 'repository.write', 'repository.read',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) ret_type = Optional.extract(ret_type) details = Optional.extract(details) @@ -523,7 +521,7 @@ def get_repo_refs(request, apiuser, repo repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin', 'repository.write', 'repository.read',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) try: # check if repo is not empty by any chance, skip quicker if it is. @@ -538,26 +536,30 @@ def get_repo_refs(request, apiuser, repo @jsonrpc_method() -def create_repo(request, apiuser, repo_name, repo_type, - owner=Optional(OAttr('apiuser')), description=Optional(''), - private=Optional(False), clone_uri=Optional(None), - landing_rev=Optional('rev:tip'), - enable_statistics=Optional(False), - enable_locking=Optional(False), - enable_downloads=Optional(False), - copy_permissions=Optional(False)): +def create_repo( + request, apiuser, repo_name, repo_type, + owner=Optional(OAttr('apiuser')), + description=Optional(''), + private=Optional(False), + clone_uri=Optional(None), + landing_rev=Optional('rev:tip'), + enable_statistics=Optional(False), + enable_locking=Optional(False), + enable_downloads=Optional(False), + copy_permissions=Optional(False)): """ Creates a repository. - * If the repository name contains "/", all the required repository - groups will be created. + * If the repository name contains "/", repository will be created inside + a repository group or nested repository groups - For example "foo/bar/baz" will create |repo| groups "foo" and "bar" - (with "foo" as parent). It will also create the "baz" repository - with "bar" as |repo| group. + For example "foo/bar/repo1" will create |repo| called "repo1" inside + group "foo/bar". You have to have permissions to access and write to + the last repository group ("bar" in this example) This command can only be run using an |authtoken| with at least - write permissions to the |repo|. + permissions to create repositories, or write permissions to + parent repository groups. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser @@ -569,9 +571,9 @@ def create_repo(request, apiuser, repo_n :type owner: Optional(str) :param description: Set the repository description. :type description: Optional(str) - :param private: + :param private: set repository as private :type private: bool - :param clone_uri: + :param clone_uri: set clone_uri :type clone_uri: str :param landing_rev: : :type landing_rev: str @@ -610,49 +612,13 @@ def create_repo(request, apiuser, repo_n } """ - schema = repo_schema.RepoSchema() - try: - data = schema.deserialize({ - 'repo_name': repo_name - }) - except colander.Invalid as e: - raise JSONRPCError("Validation failed: %s" % (e.asdict(),)) - repo_name = data['repo_name'] - (repo_name_cleaned, - parent_group_name) = RepoGroupModel()._get_group_name_and_parent( - repo_name) - - if not HasPermissionAnyApi( - 'hg.admin', 'hg.create.repository')(user=apiuser): - # check if we have admin permission for this repo group if given ! - - if parent_group_name: - repogroupid = parent_group_name - repo_group = get_repo_group_or_error(parent_group_name) + owner = validate_set_owner_permissions(apiuser, owner) - _perms = ('group.admin',) - if not HasRepoGroupPermissionAnyApi(*_perms)( - user=apiuser, group_name=repo_group.group_name): - raise JSONRPCError( - 'repository group `%s` does not exist' % ( - repogroupid,)) - else: - raise JSONRPCForbidden() - - if not has_superadmin_permission(apiuser): - if not isinstance(owner, Optional): - # forbid setting owner for non-admins - raise JSONRPCError( - 'Only RhodeCode admin can specify `owner` param') - - if isinstance(owner, Optional): - owner = apiuser.user_id - - owner = get_user_or_error(owner) - - if RepoModel().get_by_repo_name(repo_name): - raise JSONRPCError("repo `%s` already exist" % repo_name) + description = Optional.extract(description) + copy_permissions = Optional.extract(copy_permissions) + clone_uri = Optional.extract(clone_uri) + landing_commit_ref = Optional.extract(landing_rev) defs = SettingsModel().get_default_repo_settings(strip_prefix=True) if isinstance(private, Optional): @@ -666,32 +632,44 @@ def create_repo(request, apiuser, repo_n if isinstance(enable_downloads, Optional): enable_downloads = defs.get('repo_enable_downloads') - clone_uri = Optional.extract(clone_uri) - description = Optional.extract(description) - landing_rev = Optional.extract(landing_rev) - copy_permissions = Optional.extract(copy_permissions) + schema = repo_schema.RepoSchema().bind( + repo_type_options=rhodecode.BACKENDS.keys(), + # user caller + user=apiuser) try: - # create structure of groups and return the last group - repo_group = map_groups(repo_name) + schema_data = schema.deserialize(dict( + repo_name=repo_name, + repo_type=repo_type, + repo_owner=owner.username, + repo_description=description, + repo_landing_commit_ref=landing_commit_ref, + repo_clone_uri=clone_uri, + repo_private=private, + repo_copy_permissions=copy_permissions, + repo_enable_statistics=enable_statistics, + repo_enable_downloads=enable_downloads, + repo_enable_locking=enable_locking)) + except validation_schema.Invalid as err: + raise JSONRPCValidationError(colander_exc=err) + + try: data = { - 'repo_name': repo_name_cleaned, - 'repo_name_full': repo_name, - 'repo_type': repo_type, - 'repo_description': description, 'owner': owner, - 'repo_private': private, - 'clone_uri': clone_uri, - 'repo_group': repo_group.group_id if repo_group else None, - 'repo_landing_rev': landing_rev, - 'enable_statistics': enable_statistics, - 'enable_locking': enable_locking, - 'enable_downloads': enable_downloads, - 'repo_copy_permissions': copy_permissions, + 'repo_name': schema_data['repo_group']['repo_name_without_group'], + 'repo_name_full': schema_data['repo_name'], + 'repo_group': schema_data['repo_group']['repo_group_id'], + 'repo_type': schema_data['repo_type'], + 'repo_description': schema_data['repo_description'], + 'repo_private': schema_data['repo_private'], + 'clone_uri': schema_data['repo_clone_uri'], + 'repo_landing_rev': schema_data['repo_landing_commit_ref'], + 'enable_statistics': schema_data['repo_enable_statistics'], + 'enable_locking': schema_data['repo_enable_locking'], + 'enable_downloads': schema_data['repo_enable_downloads'], + 'repo_copy_permissions': schema_data['repo_copy_permissions'], } - if repo_type not in BACKENDS.keys(): - raise Exception("Invalid backend type %s" % repo_type) task = RepoModel().create(form_data=data, cur_user=owner) from celery.result import BaseAsyncResult task_id = None @@ -699,17 +677,17 @@ def create_repo(request, apiuser, repo_n task_id = task.task_id # no commit, it's done in RepoModel, or async via celery return { - 'msg': "Created new repository `%s`" % (repo_name,), + 'msg': "Created new repository `%s`" % (schema_data['repo_name'],), 'success': True, # cannot return the repo data here since fork - # cann be done async + # can be done async 'task': task_id } except Exception: log.exception( u"Exception while trying to create the repository %s", - repo_name) + schema_data['repo_name']) raise JSONRPCError( - 'failed to create repository `%s`' % (repo_name,)) + 'failed to create repository `%s`' % (schema_data['repo_name'],)) @jsonrpc_method() @@ -735,7 +713,7 @@ def add_field_to_repo(request, apiuser, repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) label = Optional.extract(label) or key description = Optional.extract(description) @@ -778,7 +756,7 @@ def remove_field_from_repo(request, apiu repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) field = RepositoryField.get_by_key_name(key, repo) if not field: @@ -800,33 +778,38 @@ def remove_field_from_repo(request, apiu @jsonrpc_method() -def update_repo(request, apiuser, repoid, name=Optional(None), - owner=Optional(OAttr('apiuser')), - group=Optional(None), - fork_of=Optional(None), - description=Optional(''), private=Optional(False), - clone_uri=Optional(None), landing_rev=Optional('rev:tip'), - enable_statistics=Optional(False), - enable_locking=Optional(False), - enable_downloads=Optional(False), - fields=Optional('')): +def update_repo( + request, apiuser, repoid, repo_name=Optional(None), + owner=Optional(OAttr('apiuser')), description=Optional(''), + private=Optional(False), clone_uri=Optional(None), + landing_rev=Optional('rev:tip'), fork_of=Optional(None), + enable_statistics=Optional(False), + enable_locking=Optional(False), + enable_downloads=Optional(False), fields=Optional('')): """ Updates a repository with the given information. This command can only be run using an |authtoken| with at least - write permissions to the |repo|. + admin permissions to the |repo|. + + * If the repository name contains "/", repository will be updated + accordingly with a repository group or nested repository groups + + For example repoid=repo-test name="foo/bar/repo-test" will update |repo| + called "repo-test" and place it inside group "foo/bar". + You have to have permissions to access and write to the last repository + group ("bar" in this example) :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: repository name or repository ID. :type repoid: str or int - :param name: Update the |repo| name. - :type name: str + :param repo_name: Update the |repo| name, including the + repository group it's in. + :type repo_name: str :param owner: Set the |repo| owner. :type owner: str - :param group: Set the |repo| group the |repo| belongs to. - :type group: str - :param fork_of: Set the master |repo| name. + :param fork_of: Set the |repo| as fork of another |repo|. :type fork_of: str :param description: Update the |repo| description. :type description: str @@ -834,69 +817,115 @@ def update_repo(request, apiuser, repoid :type private: bool :param clone_uri: Update the |repo| clone URI. :type clone_uri: str - :param landing_rev: Set the |repo| landing revision. Default is - ``tip``. + :param landing_rev: Set the |repo| landing revision. Default is ``rev:tip``. :type landing_rev: str - :param enable_statistics: Enable statistics on the |repo|, - (True | False). + :param enable_statistics: Enable statistics on the |repo|, (True | False). :type enable_statistics: bool :param enable_locking: Enable |repo| locking. :type enable_locking: bool - :param enable_downloads: Enable downloads from the |repo|, - (True | False). + :param enable_downloads: Enable downloads from the |repo|, (True | False). :type enable_downloads: bool :param fields: Add extra fields to the |repo|. Use the following example format: ``field_key=field_val,field_key2=fieldval2``. Escape ', ' with \, :type fields: str """ + repo = get_repo_or_error(repoid) + include_secrets = False - if has_superadmin_permission(apiuser): + if not has_superadmin_permission(apiuser): + validate_repo_permissions(apiuser, repoid, repo, ('repository.admin',)) + else: include_secrets = True - else: - _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + + updates = dict( + repo_name=repo_name + if not isinstance(repo_name, Optional) else repo.repo_name, + + fork_id=fork_of + if not isinstance(fork_of, Optional) else repo.fork.repo_name if repo.fork else None, + + user=owner + if not isinstance(owner, Optional) else repo.user.username, + + repo_description=description + if not isinstance(description, Optional) else repo.description, + + repo_private=private + if not isinstance(private, Optional) else repo.private, + + clone_uri=clone_uri + if not isinstance(clone_uri, Optional) else repo.clone_uri, + + repo_landing_rev=landing_rev + if not isinstance(landing_rev, Optional) else repo._landing_revision, + + repo_enable_statistics=enable_statistics + if not isinstance(enable_statistics, Optional) else repo.enable_statistics, + + repo_enable_locking=enable_locking + if not isinstance(enable_locking, Optional) else repo.enable_locking, + + repo_enable_downloads=enable_downloads + if not isinstance(enable_downloads, Optional) else repo.enable_downloads) + + ref_choices, _labels = ScmModel().get_repo_landing_revs(repo=repo) - updates = { - # update function requires this. - 'repo_name': repo.just_name - } - repo_group = group - if not isinstance(repo_group, Optional): - repo_group = get_repo_group_or_error(repo_group) - repo_group = repo_group.group_id + schema = repo_schema.RepoSchema().bind( + repo_type_options=rhodecode.BACKENDS.keys(), + repo_ref_options=ref_choices, + # user caller + user=apiuser, + old_values=repo.get_api_data()) + try: + schema_data = schema.deserialize(dict( + # we save old value, users cannot change type + repo_type=repo.repo_type, + + repo_name=updates['repo_name'], + repo_owner=updates['user'], + repo_description=updates['repo_description'], + repo_clone_uri=updates['clone_uri'], + repo_fork_of=updates['fork_id'], + repo_private=updates['repo_private'], + repo_landing_commit_ref=updates['repo_landing_rev'], + repo_enable_statistics=updates['repo_enable_statistics'], + repo_enable_downloads=updates['repo_enable_downloads'], + repo_enable_locking=updates['repo_enable_locking'])) + except validation_schema.Invalid as err: + raise JSONRPCValidationError(colander_exc=err) - repo_fork_of = fork_of - if not isinstance(repo_fork_of, Optional): - repo_fork_of = get_repo_or_error(repo_fork_of) - repo_fork_of = repo_fork_of.repo_id + # save validated data back into the updates dict + validated_updates = dict( + repo_name=schema_data['repo_group']['repo_name_without_group'], + repo_group=schema_data['repo_group']['repo_group_id'], + + user=schema_data['repo_owner'], + repo_description=schema_data['repo_description'], + repo_private=schema_data['repo_private'], + clone_uri=schema_data['repo_clone_uri'], + repo_landing_rev=schema_data['repo_landing_commit_ref'], + repo_enable_statistics=schema_data['repo_enable_statistics'], + repo_enable_locking=schema_data['repo_enable_locking'], + repo_enable_downloads=schema_data['repo_enable_downloads'], + ) + + if schema_data['repo_fork_of']: + fork_repo = get_repo_or_error(schema_data['repo_fork_of']) + validated_updates['fork_id'] = fork_repo.repo_id + + # extra fields + fields = parse_args(Optional.extract(fields), key_prefix='ex_') + if fields: + validated_updates.update(fields) try: - store_update(updates, name, 'repo_name') - store_update(updates, repo_group, 'repo_group') - store_update(updates, repo_fork_of, 'fork_id') - store_update(updates, owner, 'user') - store_update(updates, description, 'repo_description') - store_update(updates, private, 'repo_private') - store_update(updates, clone_uri, 'clone_uri') - store_update(updates, landing_rev, 'repo_landing_rev') - store_update(updates, enable_statistics, 'repo_enable_statistics') - store_update(updates, enable_locking, 'repo_enable_locking') - store_update(updates, enable_downloads, 'repo_enable_downloads') - - # extra fields - fields = parse_args(Optional.extract(fields), key_prefix='ex_') - if fields: - updates.update(fields) - - RepoModel().update(repo, **updates) + RepoModel().update(repo, **validated_updates) Session().commit() return { - 'msg': 'updated repo ID:%s %s' % ( - repo.repo_id, repo.repo_name), - 'repository': repo.get_api_data( - include_secrets=include_secrets) + 'msg': 'updated repo ID:%s %s' % (repo.repo_id, repo.repo_name), + 'repository': repo.get_api_data(include_secrets=include_secrets) } except Exception: log.exception( @@ -908,26 +937,33 @@ def update_repo(request, apiuser, repoid @jsonrpc_method() def fork_repo(request, apiuser, repoid, fork_name, owner=Optional(OAttr('apiuser')), - description=Optional(''), copy_permissions=Optional(False), - private=Optional(False), landing_rev=Optional('rev:tip')): + description=Optional(''), + private=Optional(False), + clone_uri=Optional(None), + landing_rev=Optional('rev:tip'), + copy_permissions=Optional(False)): """ Creates a fork of the specified |repo|. - * If using |RCE| with Celery this will immediately return a success - message, even though the fork will be created asynchronously. + * If the fork_name contains "/", fork will be created inside + a repository group or nested repository groups - This command can only be run using an |authtoken| with fork - permissions on the |repo|. + For example "foo/bar/fork-repo" will create fork called "fork-repo" + inside group "foo/bar". You have to have permissions to access and + write to the last repository group ("bar" in this example) + + This command can only be run using an |authtoken| with minimum + read permissions of the forked repo, create fork permissions for an user. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Set repository name or repository ID. :type repoid: str or int - :param fork_name: Set the fork name. + :param fork_name: Set the fork name, including it's repository group membership. :type fork_name: str :param owner: Set the fork owner. :type owner: str - :param description: Set the fork descripton. + :param description: Set the fork description. :type description: str :param copy_permissions: Copy permissions from parent |repo|. The default is False. @@ -965,71 +1001,63 @@ def fork_repo(request, apiuser, repoid, error: null """ - if not has_superadmin_permission(apiuser): - if not HasPermissionAnyApi('hg.fork.repository')(user=apiuser): - raise JSONRPCForbidden() repo = get_repo_or_error(repoid) repo_name = repo.repo_name - (fork_name_cleaned, - parent_group_name) = RepoGroupModel()._get_group_name_and_parent( - fork_name) - if not has_superadmin_permission(apiuser): # check if we have at least read permission for # this repo that we fork ! _perms = ( 'repository.admin', 'repository.write', 'repository.read') - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) - if not isinstance(owner, Optional): - # forbid setting owner for non super admins - raise JSONRPCError( - 'Only RhodeCode admin can specify `owner` param' - ) - # check if we have a create.repo permission if not maybe the parent - # group permission - if not HasPermissionAnyApi('hg.create.repository')(user=apiuser): - if parent_group_name: - repogroupid = parent_group_name - repo_group = get_repo_group_or_error(parent_group_name) + # check if the regular user has at least fork permissions as well + if not HasPermissionAnyApi('hg.fork.repository')(user=apiuser): + raise JSONRPCForbidden() + + # check if user can set owner parameter + owner = validate_set_owner_permissions(apiuser, owner) - _perms = ('group.admin',) - if not HasRepoGroupPermissionAnyApi(*_perms)( - user=apiuser, group_name=repo_group.group_name): - raise JSONRPCError( - 'repository group `%s` does not exist' % ( - repogroupid,)) - else: - raise JSONRPCForbidden() + description = Optional.extract(description) + copy_permissions = Optional.extract(copy_permissions) + clone_uri = Optional.extract(clone_uri) + landing_commit_ref = Optional.extract(landing_rev) + private = Optional.extract(private) - _repo = RepoModel().get_by_repo_name(fork_name) - if _repo: - type_ = 'fork' if _repo.fork else 'repo' - raise JSONRPCError("%s `%s` already exist" % (type_, fork_name)) - - if isinstance(owner, Optional): - owner = apiuser.user_id - - owner = get_user_or_error(owner) + schema = repo_schema.RepoSchema().bind( + repo_type_options=rhodecode.BACKENDS.keys(), + # user caller + user=apiuser) try: - # create structure of groups and return the last group - repo_group = map_groups(fork_name) - form_data = { - 'repo_name': fork_name_cleaned, - 'repo_name_full': fork_name, - 'repo_group': repo_group.group_id if repo_group else None, - 'repo_type': repo.repo_type, - 'description': Optional.extract(description), - 'private': Optional.extract(private), - 'copy_permissions': Optional.extract(copy_permissions), - 'landing_rev': Optional.extract(landing_rev), + schema_data = schema.deserialize(dict( + repo_name=fork_name, + repo_type=repo.repo_type, + repo_owner=owner.username, + repo_description=description, + repo_landing_commit_ref=landing_commit_ref, + repo_clone_uri=clone_uri, + repo_private=private, + repo_copy_permissions=copy_permissions)) + except validation_schema.Invalid as err: + raise JSONRPCValidationError(colander_exc=err) + + try: + data = { 'fork_parent_id': repo.repo_id, + + 'repo_name': schema_data['repo_group']['repo_name_without_group'], + 'repo_name_full': schema_data['repo_name'], + 'repo_group': schema_data['repo_group']['repo_group_id'], + 'repo_type': schema_data['repo_type'], + 'description': schema_data['repo_description'], + 'private': schema_data['repo_private'], + 'copy_permissions': schema_data['repo_copy_permissions'], + 'landing_rev': schema_data['repo_landing_commit_ref'], } - task = RepoModel().create_fork(form_data, cur_user=owner) + task = RepoModel().create_fork(data, cur_user=owner) # no commit, it's done in RepoModel, or async via celery from celery.result import BaseAsyncResult task_id = None @@ -1037,16 +1065,18 @@ def fork_repo(request, apiuser, repoid, task_id = task.task_id return { 'msg': 'Created fork of `%s` as `%s`' % ( - repo.repo_name, fork_name), + repo.repo_name, schema_data['repo_name']), 'success': True, # cannot return the repo data here since fork # can be done async 'task': task_id } except Exception: - log.exception("Exception occurred while trying to fork a repo") + log.exception( + u"Exception while trying to create fork %s", + schema_data['repo_name']) raise JSONRPCError( 'failed to fork repository `%s` as `%s`' % ( - repo_name, fork_name)) + repo_name, schema_data['repo_name'])) @jsonrpc_method() @@ -1082,7 +1112,7 @@ def delete_repo(request, apiuser, repoid repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) try: handle_forks = Optional.extract(forks) @@ -1157,7 +1187,7 @@ def invalidate_cache(request, apiuser, r repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin', 'repository.write',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) delete = Optional.extract(delete_keys) try: @@ -1236,7 +1266,7 @@ def lock(request, apiuser, repoid, locke if not has_superadmin_permission(apiuser): # check if we have at least write permission for this repo ! _perms = ('repository.admin', 'repository.write',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) # make sure normal user does not pass someone else userid, # he is not allowed to do that @@ -1347,7 +1377,7 @@ def comment_commit( repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.read', 'repository.write', 'repository.admin') - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) if isinstance(userid, Optional): userid = apiuser.user_id @@ -1438,7 +1468,7 @@ def grant_user_permission(request, apius perm = get_perm_or_error(perm) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) try: @@ -1492,7 +1522,7 @@ def revoke_user_permission(request, apiu user = get_user_or_error(userid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) try: RepoModel().revoke_user_permission(repo=repo, user=user) @@ -1560,7 +1590,7 @@ def grant_user_group_permission(request, perm = get_perm_or_error(perm) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) user_group = get_user_group_or_error(usergroupid) if not has_superadmin_permission(apiuser): @@ -1625,7 +1655,7 @@ def revoke_user_group_permission(request repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) user_group = get_user_group_or_error(usergroupid) if not has_superadmin_permission(apiuser): @@ -1701,7 +1731,7 @@ def pull(request, apiuser, repoid): repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) try: ScmModel().pull_changes(repo.repo_name, apiuser.username) @@ -1764,7 +1794,7 @@ def strip(request, apiuser, repoid, revi repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ('repository.admin',) - has_repo_permissions(apiuser, repoid, repo, _perms) + validate_repo_permissions(apiuser, repoid, repo, _perms) try: ScmModel().strip(repo, revision, branch) diff --git a/rhodecode/model/repo.py b/rhodecode/model/repo.py --- a/rhodecode/model/repo.py +++ b/rhodecode/model/repo.py @@ -377,11 +377,11 @@ class RepoModel(BaseModel): log.debug('Updating repo %s with params:%s', cur_repo, kwargs) update_keys = [ - (1, 'repo_enable_downloads'), (1, 'repo_description'), - (1, 'repo_enable_locking'), (1, 'repo_landing_rev'), (1, 'repo_private'), + (1, 'repo_enable_downloads'), + (1, 'repo_enable_locking'), (1, 'repo_enable_statistics'), (0, 'clone_uri'), (0, 'fork_id') diff --git a/rhodecode/model/scm.py b/rhodecode/model/scm.py --- a/rhodecode/model/scm.py +++ b/rhodecode/model/scm.py @@ -762,11 +762,15 @@ class ScmModel(BaseModel): :param repo: """ - hist_l = [] - choices = [] repo = self._get_repo(repo) - hist_l.append(['rev:tip', _('latest tip')]) - choices.append('rev:tip') + + hist_l = [ + ['rev:tip', _('latest tip')] + ] + choices = [ + 'rev:tip' + ] + if not repo: return choices, hist_l diff --git a/rhodecode/model/validation_schema/preparers.py b/rhodecode/model/validation_schema/preparers.py --- a/rhodecode/model/validation_schema/preparers.py +++ b/rhodecode/model/validation_schema/preparers.py @@ -21,7 +21,6 @@ import unicodedata - def strip_preparer(value): """ strips given values using .strip() function diff --git a/rhodecode/model/validation_schema/schemas/repo_schema.py b/rhodecode/model/validation_schema/schemas/repo_schema.py --- a/rhodecode/model/validation_schema/schemas/repo_schema.py +++ b/rhodecode/model/validation_schema/schemas/repo_schema.py @@ -20,8 +20,302 @@ import colander +from rhodecode.translation import _ from rhodecode.model.validation_schema import validators, preparers, types +DEFAULT_LANDING_REF = 'rev:tip' + + +def get_group_and_repo(repo_name): + from rhodecode.model.repo_group import RepoGroupModel + return RepoGroupModel()._get_group_name_and_parent( + repo_name, get_object=True) + + +@colander.deferred +def deferred_repo_type_validator(node, kw): + options = kw.get('repo_type_options', []) + return colander.OneOf([x for x in options]) + + +@colander.deferred +def deferred_repo_owner_validator(node, kw): + + def repo_owner_validator(node, value): + from rhodecode.model.db import User + existing = User.get_by_username(value) + if not existing: + msg = _(u'Repo owner with id `{}` does not exists').format(value) + raise colander.Invalid(node, msg) + + return repo_owner_validator + + +@colander.deferred +def deferred_landing_ref_validator(node, kw): + options = kw.get('repo_ref_options', [DEFAULT_LANDING_REF]) + return colander.OneOf([x for x in options]) + + +@colander.deferred +def deferred_fork_of_validator(node, kw): + old_values = kw.get('old_values') or {} + + def fork_of_validator(node, value): + from rhodecode.model.db import Repository, RepoGroup + existing = Repository.get_by_repo_name(value) + if not existing: + msg = _(u'Fork with id `{}` does not exists').format(value) + raise colander.Invalid(node, msg) + elif old_values['repo_name'] == existing.repo_name: + msg = _(u'Cannot set fork of ' + u'parameter of this repository to itself').format(value) + raise colander.Invalid(node, msg) + + return fork_of_validator + + +@colander.deferred +def deferred_can_write_to_group_validator(node, kw): + request_user = kw.get('user') + old_values = kw.get('old_values') or {} + + def can_write_to_group_validator(node, value): + """ + Checks if given repo path is writable by user. This includes checks if + user is allowed to create repositories under root path or under + repo group paths + """ + + from rhodecode.lib.auth import ( + HasPermissionAny, HasRepoGroupPermissionAny) + from rhodecode.model.repo_group import RepoGroupModel + + messages = { + 'invalid_repo_group': + _(u"Repository group `{}` does not exist"), + # permissions denied we expose as not existing, to prevent + # resource discovery + 'permission_denied': + _(u"Repository group `{}` does not exist"), + 'permission_denied_root': + _(u"You do not have the permission to store " + u"repositories in the root location.") + } + + value = value['repo_group_name'] + + is_root_location = value is types.RootLocation + # NOT initialized validators, we must call them + can_create_repos_at_root = HasPermissionAny( + 'hg.admin', 'hg.create.repository') + + # if values is root location, we simply need to check if we can write + # to root location ! + if is_root_location: + if can_create_repos_at_root(user=request_user): + # we can create repo group inside tool-level. No more checks + # are required + return + else: + # "fake" node name as repo_name, otherwise we oddly report + # the error as if it was coming form repo_group + # however repo_group is empty when using root location. + node.name = 'repo_name' + raise colander.Invalid(node, messages['permission_denied_root']) + + # parent group not exists ? throw an error + repo_group = RepoGroupModel().get_by_group_name(value) + if value and not repo_group: + raise colander.Invalid( + node, messages['invalid_repo_group'].format(value)) + + gr_name = repo_group.group_name + + # create repositories with write permission on group is set to true + create_on_write = HasPermissionAny( + 'hg.create.write_on_repogroup.true')(user=request_user) + + group_admin = HasRepoGroupPermissionAny('group.admin')( + gr_name, 'can write into group validator', user=request_user) + group_write = HasRepoGroupPermissionAny('group.write')( + gr_name, 'can write into group validator', user=request_user) + + forbidden = not (group_admin or (group_write and create_on_write)) + + # TODO: handling of old values, and detecting no-change in path + # to skip permission checks in such cases. This only needs to be + # implemented if we use this schema in forms as well + + # gid = (old_data['repo_group'].get('group_id') + # if (old_data and 'repo_group' in old_data) else None) + # value_changed = gid != safe_int(value) + # new = not old_data + + # do check if we changed the value, there's a case that someone got + # revoked write permissions to a repository, he still created, we + # don't need to check permission if he didn't change the value of + # groups in form box + # if value_changed or new: + # # parent group need to be existing + # TODO: ENDS HERE + + if repo_group and forbidden: + msg = messages['permission_denied'].format(value) + raise colander.Invalid(node, msg) + + return can_write_to_group_validator + -class RepoSchema(colander.Schema): - repo_name = colander.SchemaNode(types.GroupNameType()) +@colander.deferred +def deferred_unique_name_validator(node, kw): + request_user = kw.get('user') + old_values = kw.get('old_values') or {} + + def unique_name_validator(node, value): + from rhodecode.model.db import Repository, RepoGroup + name_changed = value != old_values.get('repo_name') + + existing = Repository.get_by_repo_name(value) + if name_changed and existing: + msg = _(u'Repository with name `{}` already exists').format(value) + raise colander.Invalid(node, msg) + + existing_group = RepoGroup.get_by_group_name(value) + if name_changed and existing_group: + msg = _(u'Repository group with name `{}` already exists').format( + value) + raise colander.Invalid(node, msg) + return unique_name_validator + + +@colander.deferred +def deferred_repo_name_validator(node, kw): + return validators.valid_name_validator + + +class GroupType(colander.Mapping): + def _validate(self, node, value): + try: + return dict(repo_group_name=value) + except Exception as e: + raise colander.Invalid( + node, '"${val}" is not a mapping type: ${err}'.format( + val=value, err=e)) + + def deserialize(self, node, cstruct): + if cstruct is colander.null: + return cstruct + + appstruct = super(GroupType, self).deserialize(node, cstruct) + validated_name = appstruct['repo_group_name'] + + # inject group based on once deserialized data + (repo_name_without_group, + parent_group_name, + parent_group) = get_group_and_repo(validated_name) + + appstruct['repo_name_without_group'] = repo_name_without_group + appstruct['repo_group_name'] = parent_group_name or types.RootLocation + if parent_group: + appstruct['repo_group_id'] = parent_group.group_id + + return appstruct + + +class GroupSchema(colander.SchemaNode): + schema_type = GroupType + validator = deferred_can_write_to_group_validator + missing = colander.null + + +class RepoGroup(GroupSchema): + repo_group_name = colander.SchemaNode( + types.GroupNameType()) + repo_group_id = colander.SchemaNode( + colander.String(), missing=None) + repo_name_without_group = colander.SchemaNode( + colander.String(), missing=None) + + +class RepoGroupAccessSchema(colander.MappingSchema): + repo_group = RepoGroup() + + +class RepoNameUniqueSchema(colander.MappingSchema): + unique_repo_name = colander.SchemaNode( + colander.String(), + validator=deferred_unique_name_validator) + + +class RepoSchema(colander.MappingSchema): + + repo_name = colander.SchemaNode( + types.RepoNameType(), + validator=deferred_repo_name_validator) + + repo_type = colander.SchemaNode( + colander.String(), + validator=deferred_repo_type_validator) + + repo_owner = colander.SchemaNode( + colander.String(), + validator=deferred_repo_owner_validator) + + repo_description = colander.SchemaNode( + colander.String(), missing='') + + repo_landing_commit_ref = colander.SchemaNode( + colander.String(), + validator=deferred_landing_ref_validator, + preparers=[preparers.strip_preparer], + missing=DEFAULT_LANDING_REF) + + repo_clone_uri = colander.SchemaNode( + colander.String(), + validator=colander.All(colander.Length(min=1)), + preparers=[preparers.strip_preparer], + missing='') + + repo_fork_of = colander.SchemaNode( + colander.String(), + validator=deferred_fork_of_validator, + missing=None) + + repo_private = colander.SchemaNode( + types.StringBooleanType(), + missing=False) + repo_copy_permissions = colander.SchemaNode( + types.StringBooleanType(), + missing=False) + repo_enable_statistics = colander.SchemaNode( + types.StringBooleanType(), + missing=False) + repo_enable_downloads = colander.SchemaNode( + types.StringBooleanType(), + missing=False) + repo_enable_locking = colander.SchemaNode( + types.StringBooleanType(), + missing=False) + + def deserialize(self, cstruct): + """ + Custom deserialize that allows to chain validation, and verify + permissions, and as last step uniqueness + """ + + # first pass, to validate given data + appstruct = super(RepoSchema, self).deserialize(cstruct) + validated_name = appstruct['repo_name'] + + # second pass to validate permissions to repo_group + second = RepoGroupAccessSchema().bind(**self.bindings) + appstruct_second = second.deserialize({'repo_group': validated_name}) + # save result + appstruct['repo_group'] = appstruct_second['repo_group'] + + # thirds to validate uniqueness + third = RepoNameUniqueSchema().bind(**self.bindings) + third.deserialize({'unique_repo_name': validated_name}) + + return appstruct diff --git a/rhodecode/model/validation_schema/schemas/search_schema.py b/rhodecode/model/validation_schema/schemas/search_schema.py --- a/rhodecode/model/validation_schema/schemas/search_schema.py +++ b/rhodecode/model/validation_schema/schemas/search_schema.py @@ -33,8 +33,7 @@ class SearchParamsSchema(colander.Mappin search_sort = colander.SchemaNode( colander.String(), missing='newfirst', - validator=colander.OneOf( - ['oldfirst', 'newfirst'])) + validator=colander.OneOf(['oldfirst', 'newfirst'])) page_limit = colander.SchemaNode( colander.Integer(), missing=10, diff --git a/rhodecode/model/validation_schema/types.py b/rhodecode/model/validation_schema/types.py --- a/rhodecode/model/validation_schema/types.py +++ b/rhodecode/model/validation_schema/types.py @@ -18,22 +18,73 @@ # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ +import re + import colander +from rhodecode.model.validation_schema import preparers +from rhodecode.model.db import User, UserGroup + + +class _RootLocation(object): + pass + +RootLocation = _RootLocation() + + +def _normalize(seperator, path): + + if not path: + return '' + elif path is colander.null: + return colander.null + + parts = path.split(seperator) -from rhodecode.model.db import User, UserGroup + def bad_parts(value): + if not value: + return False + if re.match(r'^[.]+$', value): + return False + + return True + + def slugify(value): + value = preparers.slugify_preparer(value) + value = re.sub(r'[.]{2,}', '.', value) + return value + + clean_parts = [slugify(item) for item in parts if item] + path = filter(bad_parts, clean_parts) + return seperator.join(path) + + +class RepoNameType(colander.String): + SEPARATOR = '/' + + def deserialize(self, node, cstruct): + result = super(RepoNameType, self).deserialize(node, cstruct) + if cstruct is colander.null: + return colander.null + return self._normalize(result) + + def _normalize(self, path): + return _normalize(self.SEPARATOR, path) class GroupNameType(colander.String): SEPARATOR = '/' def deserialize(self, node, cstruct): - result = super(GroupNameType, self).deserialize(node, cstruct) - return self._replace_extra_slashes(result) + if cstruct is RootLocation: + return cstruct - def _replace_extra_slashes(self, path): - path = path.split(self.SEPARATOR) - path = [item for item in path if item] - return self.SEPARATOR.join(path) + result = super(GroupNameType, self).deserialize(node, cstruct) + if cstruct is colander.null: + return colander.null + return self._normalize(result) + + def _normalize(self, path): + return _normalize(self.SEPARATOR, path) class StringBooleanType(colander.String): diff --git a/rhodecode/model/validation_schema/validators.py b/rhodecode/model/validation_schema/validators.py --- a/rhodecode/model/validation_schema/validators.py +++ b/rhodecode/model/validation_schema/validators.py @@ -36,3 +36,13 @@ def glob_validator(node, value): except Exception: msg = _(u'Invalid glob pattern') raise colander.Invalid(node, msg) + + +def valid_name_validator(node, value): + from rhodecode.model.validation_schema import types + if value is types.RootLocation: + return + + msg = _('Name must start with a letter or number. Got `{}`').format(value) + if not re.match(r'^[a-zA-z0-9]{1,}', value): + raise colander.Invalid(node, msg) diff --git a/rhodecode/tests/models/schemas/test_integration_schema.py b/rhodecode/tests/models/schemas/test_integration_schema.py --- a/rhodecode/tests/models/schemas/test_integration_schema.py +++ b/rhodecode/tests/models/schemas/test_integration_schema.py @@ -21,8 +21,6 @@ import colander import pytest -from rhodecode.model import validation_schema - from rhodecode.integrations import integration_type_registry from rhodecode.integrations.types.base import IntegrationTypeBase from rhodecode.model.validation_schema.schemas.integration_schema import ( @@ -33,14 +31,12 @@ from rhodecode.model.validation_schema.s @pytest.mark.usefixtures('app', 'autologin_user') class TestIntegrationSchema(object): - def test_deserialize_integration_schema_perms(self, backend_random, - test_repo_group, - StubIntegrationType): + def test_deserialize_integration_schema_perms( + self, backend_random, test_repo_group, StubIntegrationType): repo = backend_random.repo repo_group = test_repo_group - empty_perms_dict = { 'global': [], 'repositories': {}, diff --git a/rhodecode/tests/models/schemas/test_repo_schema.py b/rhodecode/tests/models/schemas/test_repo_schema.py new file mode 100644 --- /dev/null +++ b/rhodecode/tests/models/schemas/test_repo_schema.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2016-2016 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/ + +import colander +import pytest + +from rhodecode.model.validation_schema import types +from rhodecode.model.validation_schema.schemas import repo_schema + + +class TestRepoSchema(object): + + #TODO: + # test nested groups + + @pytest.mark.parametrize('given, expected', [ + ('my repo', 'my-repo'), + (' hello world mike ', 'hello-world-mike'), + + ('//group1/group2//', 'group1/group2'), + ('//group1///group2//', 'group1/group2'), + ('///group1/group2///group3', 'group1/group2/group3'), + ('word g1/group2///group3', 'word-g1/group2/group3'), + + ('grou p1/gro;,,##up2//.../group3', 'grou-p1/group2/group3'), + + ('group,,,/,,,/1/2/3', 'group/1/2/3'), + ('grou[]p1/gro;up2///gro up3', 'group1/group2/gro-up3'), + (u'grou[]p1/gro;up2///gro up3/ąć', u'group1/group2/gro-up3/ąć'), + ]) + def test_deserialize_repo_name(self, app, user_admin, given, expected): + + schema = repo_schema.RepoSchema().bind() + assert expected == schema.get('repo_name').deserialize(given) + + def test_deserialize(self, app, user_admin): + schema = repo_schema.RepoSchema().bind( + repo_type_options=['hg'], + user=user_admin + ) + + schema_data = schema.deserialize(dict( + repo_name='dupa', + repo_type='hg', + repo_owner=user_admin.username + )) + + assert schema_data['repo_name'] == 'dupa' + assert schema_data['repo_group'] == { + 'repo_group_id': None, + 'repo_group_name': types.RootLocation, + 'repo_name_without_group': 'dupa'} + + @pytest.mark.parametrize('given, err_key, expected_exc', [ + ('xxx/dupa','repo_group', 'Repository group `xxx` does not exist'), + ('', 'repo_name', 'Name must start with a letter or number. Got ``'), + ]) + def test_deserialize_with_bad_group_name( + self, app, user_admin, given, err_key, expected_exc): + + schema = repo_schema.RepoSchema().bind( + repo_type_options=['hg'], + user=user_admin + ) + + with pytest.raises(colander.Invalid) as excinfo: + schema.deserialize(dict( + repo_name=given, + repo_type='hg', + repo_owner=user_admin.username + )) + + assert excinfo.value.asdict()[err_key] == expected_exc + + def test_deserialize_with_group_name(self, app, user_admin, test_repo_group): + schema = repo_schema.RepoSchema().bind( + repo_type_options=['hg'], + user=user_admin + ) + + full_name = test_repo_group.group_name + '/dupa' + schema_data = schema.deserialize(dict( + repo_name=full_name, + repo_type='hg', + repo_owner=user_admin.username + )) + + assert schema_data['repo_name'] == full_name + assert schema_data['repo_group'] == { + 'repo_group_id': test_repo_group.group_id, + 'repo_group_name': test_repo_group.group_name, + 'repo_name_without_group': 'dupa'} + + def test_deserialize_with_group_name_regular_user_no_perms( + self, app, user_regular, test_repo_group): + schema = repo_schema.RepoSchema().bind( + repo_type_options=['hg'], + user=user_regular + ) + + full_name = test_repo_group.group_name + '/dupa' + with pytest.raises(colander.Invalid) as excinfo: + schema.deserialize(dict( + repo_name=full_name, + repo_type='hg', + repo_owner=user_regular.username + )) + + expected = 'Repository group `{}` does not exist'.format( + test_repo_group.group_name) + assert excinfo.value.asdict()['repo_group'] == expected diff --git a/rhodecode/tests/models/schemas/test_schema_types.py b/rhodecode/tests/models/schemas/test_schema_types.py --- a/rhodecode/tests/models/schemas/test_schema_types.py +++ b/rhodecode/tests/models/schemas/test_schema_types.py @@ -21,26 +21,82 @@ import colander import pytest -from rhodecode.model.validation_schema.types import GroupNameType +from rhodecode.model.validation_schema.types import ( + GroupNameType, RepoNameType, StringBooleanType) class TestGroupNameType(object): @pytest.mark.parametrize('given, expected', [ ('//group1/group2//', 'group1/group2'), ('//group1///group2//', 'group1/group2'), - ('group1/group2///group3', 'group1/group2/group3') + ('group1/group2///group3', 'group1/group2/group3'), ]) - def test_replace_extra_slashes_cleans_up_extra_slashes( - self, given, expected): - type_ = GroupNameType() - result = type_._replace_extra_slashes(given) + def test_normalize_path(self, given, expected): + result = GroupNameType()._normalize(given) assert result == expected - def test_deserialize_cleans_up_extra_slashes(self): + @pytest.mark.parametrize('given, expected', [ + ('//group1/group2//', 'group1/group2'), + ('//group1///group2//', 'group1/group2'), + ('group1/group2///group3', 'group1/group2/group3'), + ('v1.2', 'v1.2'), + ('/v1.2', 'v1.2'), + ('.dirs', '.dirs'), + ('..dirs', '.dirs'), + ('./..dirs', '.dirs'), + ('dir/;name;/;[];/sub', 'dir/name/sub'), + (',/,/,d,,,', 'd'), + ('/;/#/,d,,,', 'd'), + ('long../../..name', 'long./.name'), + ('long../..name', 'long./.name'), + ('../', ''), + ('\'../"../', ''), + ('c,/,/..//./,c,,,/.d/../.........c', 'c/c/.d/.c'), + ('c,/,/..//./,c,,,', 'c/c'), + ('d../..d', 'd./.d'), + ('d../../d', 'd./d'), + + ('d\;\./\,\./d', 'd./d'), + ('d\.\./\.\./d', 'd./d'), + ('d\.\./\..\../d', 'd./d'), + ]) + def test_deserialize_clean_up_name(self, given, expected): class TestSchema(colander.Schema): - field = colander.SchemaNode(GroupNameType()) + field_group = colander.SchemaNode(GroupNameType()) + field_repo = colander.SchemaNode(RepoNameType()) schema = TestSchema() - cleaned_data = schema.deserialize( - {'field': '//group1/group2///group3//'}) - assert cleaned_data['field'] == 'group1/group2/group3' + cleaned_data = schema.deserialize({ + 'field_group': given, + 'field_repo': given + }) + assert cleaned_data['field_group'] == expected + assert cleaned_data['field_repo'] == expected + + +class TestStringBooleanType(object): + + def _get_schema(self): + class Schema(colander.MappingSchema): + bools = colander.SchemaNode(StringBooleanType()) + return Schema() + + @pytest.mark.parametrize('given, expected', [ + ('1', True), + ('yEs', True), + ('true', True), + + ('0', False), + ('NO', False), + ('FALSE', False), + + ]) + def test_convert_type(self, given, expected): + schema = self._get_schema() + result = schema.deserialize({'bools':given}) + assert result['bools'] == expected + + def test_try_convert_bad_type(self): + schema = self._get_schema() + with pytest.raises(colander.Invalid): + result = schema.deserialize({'bools': 'boom'})