diff --git a/files/config/weblate_permissions.yaml b/files/config/weblate_permissions.yaml index f7a4ef110a4a44782df6b6dc18a676b0563be008..775239d2509d1c8f2856c0995fe9e3a6aa8c3647 100644 --- a/files/config/weblate_permissions.yaml +++ b/files/config/weblate_permissions.yaml @@ -361,3 +361,4 @@ superusers: - drebs - hefee - intrigeri +userdb_cache: /var/lib/weblate/userdb_cache.yml diff --git a/files/scripts/tests/data/permissions_stage1.yml b/files/scripts/tests/data/permissions_stage1.yml index 874bbd54544bf9f744ae666b52e7a629df494273..aac180941a95e1ad313c8d9c39e1bffd4a0b960d 100644 --- a/files/scripts/tests/data/permissions_stage1.yml +++ b/files/scripts/tests/data/permissions_stage1.yml @@ -15,3 +15,4 @@ roles: - suggestion.add users: superusers: +userdb_cache: /tmp/userdb_cache.yml diff --git a/files/scripts/tests/data/permissions_stage2.yml b/files/scripts/tests/data/permissions_stage2.yml index 469c199077ecec0f939661b8e6a52998aa9d709c..3fa4cb2e7f349569f264a9a747c7906993e16ed0 100644 --- a/files/scripts/tests/data/permissions_stage2.yml +++ b/files/scripts/tests/data/permissions_stage2.yml @@ -19,3 +19,4 @@ users: groups: - Users superusers: +userdb_cache: /tmp/userdb_cache.yml diff --git a/files/scripts/tests/data/permissions_stage3.yml b/files/scripts/tests/data/permissions_stage3.yml index c82d9850a9b40f21d6e6a122e936ea89db86688e..e386942cc7133baf97fa6f4e7ea626b6a11f633e 100644 --- a/files/scripts/tests/data/permissions_stage3.yml +++ b/files/scripts/tests/data/permissions_stage3.yml @@ -6,3 +6,4 @@ roles: - __deleted users: superusers: +userdb_cache: /tmp/userdb_cache.yml diff --git a/files/scripts/tests/data/superuser.yml b/files/scripts/tests/data/superuser.yml index a87abf479be7fe08ddbe53578bd4ddaf6a95522a..8563f3e6a38cc510bf929a13673d915f65544bf4 100644 --- a/files/scripts/tests/data/superuser.yml +++ b/files/scripts/tests/data/superuser.yml @@ -13,3 +13,4 @@ users: - Viewers superusers: - User1 +userdb_cache: /tmp/userdb_cache.yml diff --git a/files/scripts/tests/test_weblate_permissions.py b/files/scripts/tests/test_weblate_permissions.py index 311b471bad56dcd47243d99549a82c2bbf0a4a45..2727bd2dbecfe9e2b34c3026b8e7a82f30a94dfe 100644 --- a/files/scripts/tests/test_weblate_permissions.py +++ b/files/scripts/tests/test_weblate_permissions.py @@ -41,6 +41,8 @@ class testTP(unittest.TestCase): for i in User.objects.all(): if i.username not in self.existing_user: i.delete() + userdb_cache = pathlib.Path("/tmp/userdb_cache.yml") + userdb_cache.unlink(missing_ok=True) def test_create_roles_and_groups(self): group = Group.objects.filter(name="Test Group").first() @@ -388,3 +390,77 @@ class testTP(unittest.TestCase): ' - Users\n' ' - Viewers', ]) + + def test_renaming_user_causes_permission_enforcement_error(self): + User.objects.create(username="User2") # referenced by superuser.yml + e = tp.Config([self.datapath/'superuser.yml']) + with self.assertLogs(level="DEBUG") as cm: + tp.weblate_permission(e, True) + user1 = User.objects.filter(username="User1").first() + self.assertEqual(user1.is_superuser, True) + + user1.username = "NewUsername" + user1.save() + with self.assertLogs(level="DEBUG") as cm: + tp.weblate_permission(e, True) + + print(cm.output) + self.assertEqual(cm.output[-4:], [ + 'WARNING:weblate_permissions:The referenced user User1 renamed itself to NewUsername. Update the config accordingly.', + 'INFO:weblate_permissions.audit:Group mismatch for user(NewUsername)\n +++ expected\n --- actual\n - Users\n - Viewers', + 'INFO:weblate_permissions.audit:Wrong superuser status for NewUsername (True != False)', + 'INFO:weblate_permissions.audit:Save user(NewUsername)', + ]) + self.assertEqual(User.objects.filter(username="NewUsername").first().is_superuser, False) + + with self.assertLogs(level="DEBUG") as cm: + tp.weblate_permission(e, True) + + print(cm.output) + self.assertEqual(cm.output[-1:], [ + 'WARNING:weblate_permissions:The referenced user User1 renamed itself to NewUsername. Update the config accordingly.', + ]) + self.assertEqual(User.objects.filter(username="NewUsername").first().is_superuser, False) + + def test_prevent_overtake_permissions_of_renamed_user(self): + user2 = User.objects.create(username="User2") # referenced by superuser.yml + e = tp.Config([self.datapath/'superuser.yml']) + with self.assertLogs(level="DEBUG") as cm: + tp.weblate_permission(e, True) + user1 = User.objects.filter(username="User1").first() + self.assertEqual(user1.is_superuser, True) + + user2.delete() + user1.username = "NewUsername" + user1.save() + user_takeover = User.objects.create(username="User1") + with self.assertLogs(level="DEBUG") as cm: + tp.weblate_permission(e, True) + + print(cm.output[-8:]) + self.assertEqual(cm.output[-8:], [ + 'WARNING:weblate_permissions:User1 was taken over by someone else.', + 'WARNING:weblate_permissions:The referenced user User1 renamed itself to NewUsername. Update the config accordingly.', + 'WARNING:weblate_permissions:The Referenced user User2 was deleted. Create the account again or delete user from the configuration.', + 'INFO:weblate_permissions.audit:Group mismatch for user(NewUsername)\n +++ expected\n --- actual\n - Users\n - Viewers', + 'INFO:weblate_permissions.audit:Wrong superuser status for NewUsername (True != False)', + 'INFO:weblate_permissions.audit:Save user(NewUsername)', + 'INFO:weblate_permissions.audit:Wrong superuser status for User1 (False != True)', + 'INFO:weblate_permissions:User1 is marked (the issues are listed above): skip to save user.', + ]) + self.assertEqual(User.objects.filter(username="User1").first().is_superuser, False) + self.assertEqual(User.objects.filter(username="NewUsername").first().is_superuser, False) + + with self.assertLogs(level="DEBUG") as cm: + tp.weblate_permission(e, True) + + print(cm.output) + self.assertEqual(cm.output[-5:], [ + 'WARNING:weblate_permissions:User1 was taken over by someone else.', + 'WARNING:weblate_permissions:The referenced user User1 renamed itself to NewUsername. Update the config accordingly.', + 'WARNING:weblate_permissions:The Referenced user User2 was deleted. Create the account again or delete user from the configuration.', + 'INFO:weblate_permissions.audit:Wrong superuser status for User1 (False != True)', + 'INFO:weblate_permissions:User1 is marked (the issues are listed above): skip to save user.', + ]) + self.assertEqual(User.objects.filter(username="User1").first().is_superuser, False) + self.assertEqual(User.objects.filter(username="NewUsername").first().is_superuser, False) diff --git a/files/scripts/weblate_permissions.py b/files/scripts/weblate_permissions.py index 36e06aea8f575cc74a6d0f2782ca91648f15bb75..742655b36bbf9ece1fd576d0e23ef8abca761e2d 100644 --- a/files/scripts/weblate_permissions.py +++ b/files/scripts/weblate_permissions.py @@ -124,10 +124,14 @@ maintenance without the script interfering in the work. To run the script in `--enforceMaintenance` option. ''' +import datetime +import itertools import logging import logging.config import operator +import pathlib import yaml +from typing import List import tailsWeblate # Needed because of the side effect to setup the django application from weblate.auth.models import Group, Language, Permission, Project, Role, User @@ -231,6 +235,80 @@ class DBQuery: return True +class UserCache: + """ + Usernames are unique, but changeable in Weblate. + The problem is that using user ids in the config is not very readable for admistrators. + To solve this we keep a userdb_cache, which is a dict mapping username to ids, to detect user renames. + """ + def __init__(self, path: pathlib.Path): + self.changed = False # userdb_cache changed (needs to be saved) + self.warning_users:List[str] = [] # detected a warning for users + self.path = pathlib.Path(path) + self._read() + + def _read(self): + if self.path.exists(): + with self.path.open() as f: + self.cache = yaml.safe_load(f) + else: + self.cache = {} + + def _save(self): + with self.path.open('w') as f: + yaml.dump(self.cache, f, default_flow_style=False) + self.changed = False + + def save_if_changed(self): + if self.changed: + self._save() + + def add(self, user): + self.cache[user.username] = { + 'id':user.id, + 'email': user.email, + 'added': datetime.datetime.now().isoformat(), + } + self.changed = True + + def delete(self, username): + del self.cache[username] + self.changed = True + + def cleanup_cache(self, usernames:List[str]): + to_del = set(self.cache.keys()) - usernames + for username in to_del: + self.delete(username) + + def check(self, usernames:List[str]): + for username in usernames: + if username == "__default": + continue + + user_cache = self.cache.get(username, None) + try: + user_db = User.objects.get(username=username) + except User.DoesNotExist: + user_db = None + + if user_cache is None: + if user_db is None: + logger.warning(f"Referenced user {username} does not exist. Create the account now or delete user from the configuration.") + self.warning_users.append(username) + else: + self.add(user_db) + elif user_db is None or user_db.id != user_cache['id']: + if user_db: + logger.warning(f"{username} was taken over by someone else.") + try: + renamed_user = User.objects.get(id=user_cache['id']) + except User.DoesNotExist: + logger.warning(f"The Referenced user {username} was deleted. Create the account again or delete user from the configuration.") + else: + logger.warning(f"The referenced user {username} renamed itself to {renamed_user.username}. Update the config accordingly.") + self.warning_users.append(username) + + class WeblatePermission: def __init__(self, config, enforce): @@ -450,6 +528,18 @@ class WeblatePermission: check(a, self.groups, "More groups found, than defined.", logger.warning) def fix_users(self): + default_user = self.config.users['__default'] + usernames = set(itertools.chain.from_iterable((self.config.users, self.config.superusers))) + + # Usernames are unique, but changeable in Weblate. + # The UserCache class keeps track of username <-> id. + user_cache = UserCache(self.config.userdb_cache) + user_cache.cleanup_cache(usernames) + user_cache.check(sorted(usernames)) + marked_users = user_cache.warning_users + + user_cache.save_if_changed() + for i in sorted(User.objects.all(),key=lambda i:i.username.lower()): changed = False @@ -457,7 +547,7 @@ class WeblatePermission: # Ignore inactive users, if they are not listed explictitly continue - e = self.config.users.get(i.username, self.config.users['__default']) + e = self.config.users.get(i.username, default_user) dbquery = DBQuery(i.groups, 'name', Group.objects) if not self.attribute(dbquery)(e['groups'], f"Group mismatch for user({i.username})", self.logfunc): @@ -466,7 +556,11 @@ class WeblatePermission: changed = True if not self.check_func(i,'is_active', e.get('is_active'), f"Wrong is_active status for {i.username}", self.logfunc, False): changed = True + if changed and self.enforce: + if i.username in marked_users: + logger.info(f"{i.username} is marked (the issues are listed above): skip to save user.") + continue auditlogger.info(f"Save user({i.username})") i.save()