diff --git a/configs/development.ini b/configs/development.ini index 7a367583..54a2b0bd 100644 --- a/configs/development.ini +++ b/configs/development.ini @@ -74,6 +74,10 @@ prefix = / use = egg:rhodecode-enterprise-ce +user.quotas.enabled = false +user.quotas.disk_space_mb = 100 +user.quotas.repository_count = 5 + ; enable proxy prefix middleware, defined above #filter-with = proxy-prefix diff --git a/configs/production.ini b/configs/production.ini index defb2512..61c76441 100644 --- a/configs/production.ini +++ b/configs/production.ini @@ -68,6 +68,10 @@ prefix = / use = egg:rhodecode-enterprise-ce +user.quotas.enabled = false +user.quotas.disk_space_mb = 100 +user.quotas.repository_count = 5 + ; enable proxy prefix middleware, defined above #filter-with = proxy-prefix diff --git a/rhodecode/apps/admin/tests/test_admin_repos.py b/rhodecode/apps/admin/tests/test_admin_repos.py index dca359ac..99f75b2c 100644 --- a/rhodecode/apps/admin/tests/test_admin_repos.py +++ b/rhodecode/apps/admin/tests/test_admin_repos.py @@ -19,6 +19,7 @@ import urllib.request import urllib.parse import urllib.error +from unittest.mock import PropertyMock import mock import pytest @@ -29,6 +30,7 @@ from rhodecode.lib.utils2 import safe_str from rhodecode.lib import helpers as h from rhodecode.model.db import Repository, RepoGroup, UserRepoToPerm, User, Permission from rhodecode.model.meta import Session +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo import RepoModel from rhodecode.model.repo_group import RepoGroupModel from rhodecode.model.user import UserModel @@ -85,7 +87,7 @@ class TestAdminRepos(object): description_unicode = "description for newly created repo" + suffix description = description_unicode - response = self.app.post( + self.app.post( route_path("repo_create"), fixture._get_repo_create_params( repo_private=False, @@ -99,6 +101,79 @@ class TestAdminRepos(object): self.assert_repository_is_created_correctly(repo_name, description, backend) + def test_create_fails_due_to_quota(self, backend, monkeypatch): + import rhodecode as app + + monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + + session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) + csrf_token = auth.get_csrf_token(session) + + repo_name_unicode = backend.new_repo_name(suffix="no_quota") + repo_name = repo_name_unicode + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + user.update_quotas( + max_disk_space_mb_allowance=200, + max_repository_count_allowance=2, + current_repos_count=2, + used_disk_space_bytes=0, + ) + + self.app.post( + route_path("repo_create"), + fixture._get_repo_create_params( + repo_name=repo_name, + repo_type=backend.alias, + repo_description="no_quota", + csrf_token=csrf_token, + ), + status=302, + ) + + response = self.app.get(route_path("repo_creating_check", repo_name=repo_name)) + assert response.json == {"result": False} + + flash_msg = "User quotas exceeded. You can't create more repositories." + assert_session_flash(response, flash_msg) + + new_repo = RepoModel().get_by_repo_name(repo_name) + assert new_repo is None + + def test_create_count_user_repos(self, backend, monkeypatch): + import rhodecode as app + + monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + + session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) + csrf_token = auth.get_csrf_token(session) + + repo_name_unicode = backend.new_repo_name(suffix="no_quota") + repo_name = repo_name_unicode + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + user.update_quotas( + max_disk_space_mb_allowance=200, + max_repository_count_allowance=2, + current_repos_count=0, + used_disk_space_bytes=0, + ) + + self.app.post( + route_path("repo_create"), + fixture._get_repo_create_params( + repo_name=repo_name, + repo_type=backend.alias, + repo_description="quota", + csrf_token=csrf_token, + ), + status=302, + ) + + self.assert_repository_is_created_correctly(repo_name, "quota", backend) + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + assert user.get_quotas()["current_repos_count"] == 1 + def test_create_numeric_name(self, autologin_user, backend, csrf_token): numeric_repo = "1234" repo_name = numeric_repo diff --git a/rhodecode/apps/admin/views/repositories.py b/rhodecode/apps/admin/views/repositories.py index 1a49832d..e47d75d6 100644 --- a/rhodecode/apps/admin/views/repositories.py +++ b/rhodecode/apps/admin/views/repositories.py @@ -36,6 +36,7 @@ from rhodecode.lib.utils import repo_name_slug from rhodecode.lib.utils2 import safe_int, safe_str from rhodecode.model.forms import RepoForm from rhodecode.model.permission import PermissionModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo import RepoModel from rhodecode.model.scm import RepoList, RepoGroupList, ScmModel from rhodecode.model.settings import SettingsModel @@ -220,6 +221,11 @@ class AdminReposView(BaseAppView, DataGridAppView): copy_permissions = form_result.get("repo_copy_permissions") # create is done sometimes async on celery, db transaction # management is handled there. + + quota_model = UserQuotaModel(self._rhodecode_db_user) + if not quota_model.is_repo_creation_allowed(): + raise UserQuotaModel.QuotaError("User quotas exceeded. You can't create more repositories.") + task = RepoModel().create(form_result, self._rhodecode_user.user_id) task_id = get_task_id(task) except formencode.Invalid as errors: @@ -234,6 +240,11 @@ class AdminReposView(BaseAppView, DataGridAppView): ) return Response(html) + except UserQuotaModel.QuotaError as e: + msg = str(e) + log.error(msg) + h.flash(msg, category="error") + raise HTTPFound(h.route_path("home")) except Exception as e: msg = self._log_creation_exception(e, form_result.get("repo_name")) h.flash(msg, category="error") diff --git a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py index 94dbe7f8..de404afb 100644 --- a/rhodecode/apps/repository/tests/test_repo_settings_advanced.py +++ b/rhodecode/apps/repository/tests/test_repo_settings_advanced.py @@ -19,11 +19,13 @@ import pytest from unittest.mock import patch, MagicMock from rhodecode.apps.repository.views.repo_settings_advanced import RepoSettingsAdvancedView +from rhodecode.bootstrap import TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS +from rhodecode.lib import auth from rhodecode.lib.str_utils import safe_str -from rhodecode.model.db import Repository +from rhodecode.model.db import Repository, User from rhodecode.model.meta import Session from rhodecode.model.repo import RepoModel, ForksAction -from rhodecode.tests import HG_REPO, GIT_REPO, assert_session_flash, no_newline_id_generator +from rhodecode.tests import HG_REPO, GIT_REPO, assert_session_flash, no_newline_id_generator, login_user_session from rhodecode.tests.fixtures.rc_fixture import Fixture from rhodecode.tests.utils import repo_on_filesystem from rhodecode.tests.routes import route_path @@ -115,6 +117,42 @@ class TestAdminRepoSettingsAdvanced(object): assert RepoModel().get_by_repo_name(repo_name) is None assert not repo_on_filesystem(repo_name_str) + def test_advanced_repo_delete_decrement_repo_quota(self, backend, monkeypatch): + import rhodecode as app + + monkeypatch.setitem(app.CONFIG, "user.quotas.enabled", True) + + session = login_user_session(self.app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS) + csrf_token = auth.get_csrf_token(session) + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + user.update_quotas( + max_disk_space_mb_allowance=200, + max_repository_count_allowance=2, + current_repos_count=1, + used_disk_space_bytes=0, + ) + + repo = backend.create_repo(name_suffix="quota") + repo.user = user + Session().commit() + + repo_name = repo.repo_name + repo_name_str = safe_str(repo.repo_name) + + response = self.app.post( + route_path("edit_repo_advanced_delete", repo_name=repo_name_str), params={"csrf_token": csrf_token} + ) + assert_session_flash(response, "Deleted repository `{}`".format(repo_name)) + response.follow() + + user: User = User.get_by_username(TEST_USER_REGULAR_LOGIN) + + # check if repo was deleted from db + assert RepoModel().get_by_repo_name(repo_name) is None + assert not repo_on_filesystem(repo_name_str) + assert user.get_quotas()["current_repos_count"] == 0 + @pytest.mark.parametrize("suffix", ["", "ąęł", "123"], ids=no_newline_id_generator) def test_delete_broken_repo(self, autologin_user, backend, suffix, csrf_token): repo = backend.create_repo(name_suffix=suffix, private_repo=True) diff --git a/rhodecode/apps/repository/views/repo_settings_advanced.py b/rhodecode/apps/repository/views/repo_settings_advanced.py index b4bc2bb3..feff3b09 100644 --- a/rhodecode/apps/repository/views/repo_settings_advanced.py +++ b/rhodecode/apps/repository/views/repo_settings_advanced.py @@ -32,6 +32,7 @@ from rhodecode.lib.utils2 import safe_int from rhodecode.lib.vcs import RepositoryError from rhodecode.model.db import Session, UserFollowing, User, Repository from rhodecode.model.permission import PermissionModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo import RepoModel, ForksAction from rhodecode.model.scm import ScmModel @@ -157,6 +158,9 @@ class RepoSettingsAdvancedView(RepoAppView): self.request.POST.get("backup", "false") ) # if this option is absent, this means that on FE side checkbox was unchecked delete_res = RepoModel().delete(self.db_repo, fs_backup=backup) + repo_owner = self.db_repo.user + quota_model = UserQuotaModel(repo_owner) + quota_model.decrement_repo_count() fork_action = delete_res.fork_action_result.action fork_cnt = delete_res.fork_action_result.fork_count diff --git a/rhodecode/config/config_maker.py b/rhodecode/config/config_maker.py index 5f710d83..70039db5 100644 --- a/rhodecode/config/config_maker.py +++ b/rhodecode/config/config_maker.py @@ -174,6 +174,10 @@ def sanitize_settings_and_apply_defaults(global_config, settings): settings_maker.make_setting("celery.deduplicate_lock_store", "redis://redis:6379/9", parser="string") settings_maker.make_setting("celery.lock_ttl_seconds", 3600, parser="int") + settings_maker.make_setting("user.quotas.enabled", False, parser="bool") + settings_maker.make_setting("user.quotas.disk_space_mb", 200, parser="int") + settings_maker.make_setting("user.quotas.repository_count", 5, parser="int") + settings_maker.make_setting("exception_tracker.send_email", False, parser="bool") settings_maker.make_setting("exception_tracker.email_prefix", "[RHODECODE ERROR]", default_when_empty=True) diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index 9a1d5065..4ab112a4 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -59,6 +59,7 @@ from rhodecode.model.db import ( ) from rhodecode.model.permission import PermissionModel from rhodecode.model.pull_request import PullRequestModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.settings import SettingsModel @@ -279,6 +280,9 @@ def create_repo(form_data, cur_user): repo=audit_logger.RepoWrap(repo_name=repo_name, repo_id=repo_id), ) + quota_model = UserQuotaModel(cur_user) + quota_model.increment_repo_count() + Session().commit() PermissionModel().trigger_permission_flush() diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py index 32627a72..1bb50f9d 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -1256,6 +1256,15 @@ class User(Base, BaseModel): if user: return user + def update_quotas(self, **kwargs): + quotas = self.user_data.get("quotas", {}) + quotas.update(**kwargs) + self.update_userdata(quotas=quotas) + Session().commit() + + def get_quotas(self): + return self.user_data.get("quotas", {}) + def update_userdata(self, **kwargs): usr = self old = usr.user_data diff --git a/rhodecode/model/quota_model.py b/rhodecode/model/quota_model.py new file mode 100644 index 00000000..f9c13916 --- /dev/null +++ b/rhodecode/model/quota_model.py @@ -0,0 +1,110 @@ +import logging +from functools import wraps + +from rhodecode.model.db import User +import rhodecode as app + + +log = logging.getLogger(__name__) + + +def always_valid_for_super_admin(f): + @wraps(f) + def inner(self, *args, **kwargs): + if self.user.is_admin: + return True + return f(self, *args, **kwargs) + + return inner + + +def skip_for_super_admin(f): + @wraps(f) + def inner(self, *args, **kwargs): + if self.user.is_admin: + log.debug(f"Skipping {f.__name__} for super admin.") + return + return f(self, *args, **kwargs) + + return inner + + +class UserQuotaModel: + """ + NOTE: quotas are applied only when a user registers itself or the admin adds a user, + in plugins there are more ways to add a user. + """ + + class QuotaError(Exception): + pass + + def __init__(self, user: User): + self._settings = app.CONFIG + self.enabled = self._settings.get("user.quotas.enabled") + self.default_max_disk_space_mb_allowance = self._settings.get("user.quotas.disk_space_mb") + self.default_max_repository_count_allowance = self._settings.get("user.quotas.repository_count") + self.user = user + + def init_quotas(self): + if not self.enabled: + log.debug("quotas are disabled") + return + + self.user.update_quotas( + max_disk_space_mb_allowance=self.default_max_disk_space_mb_allowance, + max_repository_count_allowance=self.default_max_repository_count_allowance, + current_repos_count=0, + used_disk_space_bytes=0, + ) + + @always_valid_for_super_admin + def is_repo_creation_allowed(self): + if not self.enabled: + return True + + if self._user_quotas_valid(): + u_quotas = self.user.get_quotas() + return u_quotas.get("max_repository_count_allowance") > u_quotas.get("current_repos_count") + + log.warning("User quotas not present, probably old user, allowing repo creation.") + return True + + @skip_for_super_admin + def increment_repo_count(self): + if not self.enabled: + return + + if self._user_quotas_valid(): + u_quotas = self.user.get_quotas() + new_count = u_quotas["current_repos_count"] + 1 + self.user.update_quotas(current_repos_count=new_count) + + log.warning("User quotas not present, probably old user, skipping.") + + @skip_for_super_admin + def decrement_repo_count(self): + if not self.enabled: + return + + u_quotas = self.user.get_quotas() + if self._user_quotas_valid() and u_quotas["current_repos_count"] > 0: + new_count = u_quotas["current_repos_count"] - 1 + self.user.update_quotas(current_repos_count=new_count) + + log.warning("User quotas not present, probably old user, skipping.") + + def _user_quotas_valid(self): + u_quotas = self.user.get_quotas() + for key in [ + "max_disk_space_mb_allowance", + "max_repository_count_allowance", + "current_repos_count", + "used_disk_space_bytes", + ]: + if u_quotas.get(key) is None: + log.error( + f"User {self.user.username} has no quota value for {key}, probably quota initialization was not done." + ) + return False + + return True diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py index b303b50d..9bc2552f 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -62,6 +62,7 @@ from rhodecode.model.db import ( ) from rhodecode.model.meta import Session from rhodecode.model.auth_token import AuthTokenModel +from rhodecode.model.quota_model import UserQuotaModel from rhodecode.model.repo_group import RepoGroupModel log = logging.getLogger(__name__) @@ -409,6 +410,10 @@ class UserModel(BaseModel): new_user.update_userdata(language=language) new_user.update_userdata(notification_status=True) + if new_user.is_new_user: + qm = UserQuotaModel(new_user) + qm.init_quotas() + self.sa.add(new_user) if not edit and create_repo_group: @@ -443,6 +448,8 @@ class UserModel(BaseModel): form_data["extern_type"] = extern_type new_user = self.create(form_data) + qm = UserQuotaModel(new_user) + qm.init_quotas() self.sa.add(new_user) self.sa.flush()