From 53c06069b542d5dffd2b8fa2db63f952ebd54e1b Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Fri, 4 Nov 2022 00:41:01 +0100 Subject: [PATCH] New UID/GID allocation approach Previously Unix UIDs/GIDs were allocated by using the highest used ID + 1. This caused ID reuse when the newest user/group was deleted. In addition, the implementation did not work on MariaDB (at all, it was not possible to create users/groups). The new approach accounts for all IDs ever used regardless of whether or not users/groups are deleted. It always allocates the lowest ID in the configured range that was never used. Aside from the different allocation algorithm, this change introduces a generic locking mechanism and prerequisites for testing migration scripts. --- tests/migrations/__init__.py | 0 tests/migrations/test_missing_locks.py | 16 ++ tests/migrations/versions/__init__.py | 0 ...07202a6c8_locking_and_new_id_allocation.py | 144 ++++++++++++++++ tests/models/test_misc.py | 35 +++- tests/models/test_user.py | 123 +++++++++++--- tests/utils.py | 134 ++++++++++----- ...07202a6c8_locking_and_new_id_allocation.py | 158 ++++++++++++++++++ uffd/models/__init__.py | 8 +- uffd/models/misc.py | 37 ++++ uffd/models/user.py | 157 ++++++++++++----- uffd/translations/de/LC_MESSAGES/messages.mo | Bin 40047 -> 40147 bytes uffd/translations/de/LC_MESSAGES/messages.po | 18 +- uffd/views/group.py | 6 +- 14 files changed, 720 insertions(+), 116 deletions(-) create mode 100644 tests/migrations/__init__.py create mode 100644 tests/migrations/test_missing_locks.py create mode 100644 tests/migrations/versions/__init__.py create mode 100644 tests/migrations/versions/test_aeb07202a6c8_locking_and_new_id_allocation.py create mode 100644 uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py diff --git a/tests/migrations/__init__.py b/tests/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/migrations/test_missing_locks.py b/tests/migrations/test_missing_locks.py new file mode 100644 index 00000000..42618530 --- /dev/null +++ b/tests/migrations/test_missing_locks.py @@ -0,0 +1,16 @@ +from uffd.database import db +from uffd.models.misc import lock_table, Lock + +from tests.utils import MigrationTestCase + +class TestForMissingLockRows(MigrationTestCase): + def test_check_missing_lock_rows(self): + self.upgrade('head') + existing_locks = {row[0] for row in db.session.execute(db.select([lock_table.c.name])).fetchall()} + for name in Lock.ALL_LOCKS - existing_locks: + self.fail(f'Lock "{name}" is missing. Make sure to add a migration that inserts it.') + +# Add something like this: +# conn = op.get_bind() +# lock_table = sa.table('lock', sa.column('name')) +# conn.execute(sa.insert(lock_table).values(name='NAME')) diff --git a/tests/migrations/versions/__init__.py b/tests/migrations/versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/migrations/versions/test_aeb07202a6c8_locking_and_new_id_allocation.py b/tests/migrations/versions/test_aeb07202a6c8_locking_and_new_id_allocation.py new file mode 100644 index 00000000..5d5e9655 --- /dev/null +++ b/tests/migrations/versions/test_aeb07202a6c8_locking_and_new_id_allocation.py @@ -0,0 +1,144 @@ +from uffd.database import db +from uffd.models.misc import lock_table, Lock + +from tests.utils import MigrationTestCase + +user_table = db.table('user', + db.column('id'), + db.column('unix_uid'), + db.column('loginname'), + db.column('displayname'), + db.column('primary_email_id'), + db.column('is_service_user'), +) + +user_email_table = db.table('user_email', + db.column('id'), + db.column('address'), + db.column('address_normalized'), + db.column('verified'), +) + +group_table = db.table('group', + db.column('id'), + db.column('unix_gid'), + db.column('name'), + db.column('description') +) + +uid_allocation_table = db.table('uid_allocation', db.column('id')) +gid_allocation_table = db.table('gid_allocation', db.column('id')) + +class TestMigration(MigrationTestCase): + REVISION = 'aeb07202a6c8' + + def setUpApp(self): + self.app.config['USER_MIN_UID'] = 10000 + self.app.config['USER_MAX_UID'] = 10005 + self.app.config['USER_SERVICE_MIN_UID'] = 10006 + self.app.config['USER_SERVICE_MAX_UID'] = 10010 + self.app.config['GROUP_MIN_GID'] = 20000 + self.app.config['GROUP_MAX_GID'] = 20005 + + def create_user(self, uid): + db.session.execute(db.insert(user_email_table).values( + address=f'email{uid}@example.com', + address_normalized=f'email{uid}@example.com', + verified=True + )) + email_id = db.session.execute( + db.select([user_email_table.c.id]) + .where(user_email_table.c.address == f'email{uid}@example.com') + ).scalar() + db.session.execute(db.insert(user_table).values( + unix_uid=uid, + loginname=f'user{uid}', + displayname='user', + primary_email_id=email_id, + is_service_user=False + )) + + def create_group(self, gid): + db.session.execute(db.insert(group_table).values(unix_gid=gid, name=f'group{gid}', description='')) + + def fetch_uid_allocations(self): + return [row[0] for row in db.session.execute( + db.select([uid_allocation_table]) + .order_by(uid_allocation_table.c.id) + ).fetchall()] + + def fetch_gid_allocations(self): + return [row[0] for row in db.session.execute( + db.select([gid_allocation_table]) + .order_by(gid_allocation_table.c.id) + ).fetchall()] + + def test_empty(self): + # No users/groups + self.upgrade() + self.assertEqual(self.fetch_uid_allocations(), []) + self.assertEqual(self.fetch_gid_allocations(), []) + + def test_gid_first_minus_one(self): + self.create_group(19999) + self.upgrade() + self.assertEqual(self.fetch_gid_allocations(), [19999]) + + def test_gid_first(self): + self.create_group(20000) + self.upgrade() + self.assertEqual(self.fetch_gid_allocations(), [20000]) + + def test_gid_first_plus_one(self): + self.create_group(20001) + self.upgrade() + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001]) + + def test_gid_last_minus_one(self): + self.create_group(20004) + self.upgrade() + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004]) + + def test_gid_last(self): + self.create_group(20005) + self.upgrade() + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005]) + + def test_gid_last_plus_one(self): + self.create_group(20006) + self.upgrade() + self.assertEqual(self.fetch_gid_allocations(), [20006]) + + def test_gid_complex(self): + self.create_group(10) + self.create_group(20001) + self.create_group(20003) + self.create_group(20010) + self.upgrade() + self.assertEqual(self.fetch_gid_allocations(), [10, 20000, 20001, 20002, 20003, 20010]) + + # The code for UIDs is mostly the same as for GIDs, so we don't test all + # the edge cases again. + def test_uid_different_ranges(self): + self.create_user(10) + self.create_user(10000) + self.create_user(10002) + self.create_user(10007) + self.create_user(10009) + self.create_user(90000) + self.upgrade() + self.assertEqual(self.fetch_uid_allocations(), [10, 10000, 10001, 10002, 10006, 10007, 10008, 10009, 90000]) + + def test_uid_same_ranges(self): + self.app.config['USER_MIN_UID'] = 10000 + self.app.config['USER_MAX_UID'] = 10010 + self.app.config['USER_SERVICE_MIN_UID'] = 10000 + self.app.config['USER_SERVICE_MAX_UID'] = 10010 + self.create_user(10) + self.create_user(10000) + self.create_user(10002) + self.create_user(10007) + self.create_user(10009) + self.create_user(90000) + self.upgrade() + self.assertEqual(self.fetch_uid_allocations(), [10, 10000, 10001, 10002, 10003, 10004, 10005, 10006, 10007, 10008, 10009, 90000]) diff --git a/tests/models/test_misc.py b/tests/models/test_misc.py index 4b208658..aebd62b3 100644 --- a/tests/models/test_misc.py +++ b/tests/models/test_misc.py @@ -1,12 +1,15 @@ +import time +import threading + from sqlalchemy.exc import IntegrityError from uffd.database import db -from uffd.models import FeatureFlag +from uffd.models import FeatureFlag, Lock from uffd.models.misc import feature_flag_table -from tests.utils import UffdTestCase +from tests.utils import ModelTestCase -class TestFeatureFlagModel(UffdTestCase): +class TestFeatureFlag(ModelTestCase): def test_disabled(self): flag = FeatureFlag('foo') self.assertFalse(flag) @@ -53,3 +56,29 @@ class TestFeatureFlagModel(UffdTestCase): with self.assertRaises(IntegrityError): flag.enable() self.assertTrue(flag) + +class TestLock(ModelTestCase): + DISABLE_SQLITE_MEMORY_DB = True + + def setUpApp(self): + self.lock = Lock('testlock') + + def run_lock_test(self): + result = [] + def func(): + with self.app.test_request_context(): + self.lock.acquire() + result.append('bar') + t = threading.Thread(target=func) + t.start() + time.sleep(1) + result.append('foo') + time.sleep(1) + db.session.rollback() + t.join() + return result + + def test_lock2(self): + self.assertEqual(self.run_lock_test(), ['bar', 'foo']) + self.lock.acquire() + self.assertEqual(self.run_lock_test(), ['foo', 'bar']) diff --git a/tests/models/test_user.py b/tests/models/test_user.py index 6d81e9ec..71416643 100644 --- a/tests/models/test_user.py +++ b/tests/models/test_user.py @@ -3,9 +3,9 @@ import datetime import sqlalchemy from uffd.database import db -from uffd.models import User, UserEmail, Group, FeatureFlag +from uffd.models import User, UserEmail, Group, FeatureFlag, IDAlreadyAllocatedError, IDRangeExhaustedError -from tests.utils import UffdTestCase +from tests.utils import UffdTestCase, ModelTestCase class TestUserModel(UffdTestCase): def test_has_permission(self): @@ -36,9 +36,9 @@ class TestUserModel(UffdTestCase): self.app.config['USER_MIN_UID'] = 10000 self.app.config['USER_MAX_UID'] = 18999 self.app.config['USER_SERVICE_MIN_UID'] = 19000 - self.app.config['USER_SERVICE_MAX_UID'] =19999 - User.query.delete() - db.session.commit() + self.app.config['USER_SERVICE_MAX_UID'] = 19999 + db.drop_all() + db.create_all() user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com') user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com') user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com') @@ -53,6 +53,12 @@ class TestUserModel(UffdTestCase): db.session.add(user3) db.session.commit() self.assertEqual(user3.unix_uid, 10003) + db.session.delete(user2) + db.session.commit() + user4 = User(loginname='user4', displayname='user4', primary_email_address='user4@example.com') + db.session.add(user4) + db.session.commit() + self.assertEqual(user4.unix_uid, 10004) service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True) service1 = User(loginname='service1', displayname='service1', primary_email_address='service1@example.com', is_service_user=True) db.session.add_all([service0, service1]) @@ -65,8 +71,8 @@ class TestUserModel(UffdTestCase): self.app.config['USER_MAX_UID'] = 19999 self.app.config['USER_SERVICE_MIN_UID'] = 10000 self.app.config['USER_SERVICE_MAX_UID'] = 19999 - User.query.delete() - db.session.commit() + db.drop_all() + db.create_all() user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com') service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True) user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com') @@ -79,15 +85,15 @@ class TestUserModel(UffdTestCase): def test_unix_uid_generation_overflow(self): self.app.config['USER_MIN_UID'] = 10000 self.app.config['USER_MAX_UID'] = 10001 - User.query.delete() - db.session.commit() + db.drop_all() + db.create_all() user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com') user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com') db.session.add_all([user0, user1]) db.session.commit() self.assertEqual(user0.unix_uid, 10000) self.assertEqual(user1.unix_uid, 10001) - with self.assertRaises(sqlalchemy.exc.IntegrityError): + with self.assertRaises(sqlalchemy.exc.StatementError): user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com') db.session.add(user2) db.session.commit() @@ -285,32 +291,105 @@ class TestUserEmailModel(UffdTestCase): self.assert_cannot_add_address(user=admin, address='bar@example.com', verified=True) self.assert_cannot_add_address(user=admin, address='BAR@example.com', verified=True) -class TestGroupModel(UffdTestCase): +class TestIDAllocator(ModelTestCase): + def allocate_gids(self, *gids): + for gid in gids: + Group.unix_gid_allocator.allocate(gid) + + def fetch_gid_allocations(self): + return [row[0] for row in db.session.execute( + db.select([Group.unix_gid_allocator.allocation_table]) + .order_by(Group.unix_gid_allocator.allocation_table.c.id) + ).fetchall()] + + def test_empty(self): + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000) + self.assertEqual(self.fetch_gid_allocations(), [20000]) + + def test_first(self): + self.allocate_gids(20000) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20001) + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001]) + + def test_out_of_range_before(self): + self.allocate_gids(19998) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000) + self.assertEqual(self.fetch_gid_allocations(), [19998, 20000]) + + def test_out_of_range_right_before(self): + self.allocate_gids(19999) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000) + self.assertEqual(self.fetch_gid_allocations(), [19999, 20000]) + + def test_out_of_range_after(self): + self.allocate_gids(20006) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000) + self.assertEqual(self.fetch_gid_allocations(), [20000, 20006]) + + def test_gap_at_beginning(self): + self.allocate_gids(20001) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20000) + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001]) + + def test_multiple_gaps(self): + self.allocate_gids(20000, 20001, 20003, 20005) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20002) + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20005]) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20004) + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005]) + + def test_last(self): + self.allocate_gids(20000, 20001, 20002, 20003, 20004) + self.assertEqual(Group.unix_gid_allocator.auto(20000, 20005), 20005) + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005]) + + def test_overflow(self): + self.allocate_gids(20000, 20001, 20002, 20003, 20004, 20005) + with self.assertRaises(IDRangeExhaustedError): + Group.unix_gid_allocator.auto(20000, 20005) + self.assertEqual(self.fetch_gid_allocations(), [20000, 20001, 20002, 20003, 20004, 20005]) + + def test_conflict(self): + self.allocate_gids(20000) + with self.assertRaises(IDAlreadyAllocatedError): + self.allocate_gids(20000) + self.assertEqual(self.fetch_gid_allocations(), [20000]) + +class TestGroup(ModelTestCase): def test_unix_gid_generation(self): self.app.config['GROUP_MIN_GID'] = 20000 self.app.config['GROUP_MAX_GID'] = 49999 - Group.query.delete() - db.session.commit() group0 = Group(name='group0', description='group0') group1 = Group(name='group1', description='group1') group2 = Group(name='group2', description='group2') - db.session.add_all([group0, group1, group2]) + group3 = Group(name='group3', description='group3', unix_gid=20004) + db.session.add_all([group0, group1, group2, group3]) db.session.commit() self.assertEqual(group0.unix_gid, 20000) self.assertEqual(group1.unix_gid, 20001) self.assertEqual(group2.unix_gid, 20002) - db.session.delete(group1) + self.assertEqual(group3.unix_gid, 20004) + db.session.delete(group2) db.session.commit() - group3 = Group(name='group3', description='group3') - db.session.add(group3) + group4 = Group(name='group4', description='group4') + group5 = Group(name='group5', description='group5') + db.session.add_all([group4, group5]) db.session.commit() - self.assertEqual(group3.unix_gid, 20003) + self.assertEqual(group4.unix_gid, 20003) + self.assertEqual(group5.unix_gid, 20005) - def test_unix_gid_generation(self): + def test_unix_gid_generation_conflict(self): self.app.config['GROUP_MIN_GID'] = 20000 - self.app.config['GROUP_MAX_GID'] = 20001 - Group.query.delete() + self.app.config['GROUP_MAX_GID'] = 49999 + group0 = Group(name='group0', description='group0', unix_gid=20023) + db.session.add(group0) db.session.commit() + with self.assertRaises(IDAlreadyAllocatedError): + Group(name='group1', description='group1', unix_gid=20023) + + def test_unix_gid_generation_overflow(self): + self.app.config['GROUP_MIN_GID'] = 20000 + self.app.config['GROUP_MAX_GID'] = 20001 group0 = Group(name='group0', description='group0') group1 = Group(name='group1', description='group1') db.session.add_all([group0, group1]) @@ -318,7 +397,7 @@ class TestGroupModel(UffdTestCase): self.assertEqual(group0.unix_gid, 20000) self.assertEqual(group1.unix_gid, 20001) db.session.commit() - with self.assertRaises(sqlalchemy.exc.IntegrityError): + with self.assertRaises(sqlalchemy.exc.StatementError): group2 = Group(name='group2', description='group2') db.session.add(group2) db.session.commit() diff --git a/tests/utils.py b/tests/utils.py index afea2e0f..55a6164e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,6 +2,7 @@ import os import unittest from flask import url_for +import flask_migrate from uffd import create_app, db from uffd.models import User, Group, Mail @@ -21,43 +22,10 @@ def db_flush(): db.session.rollback() db.session.expire_all() -class UffdTestCase(unittest.TestCase): - def get_user(self): - return User.query.filter_by(loginname='testuser').one_or_none() - - def get_admin(self): - return User.query.filter_by(loginname='testadmin').one_or_none() - - def get_admin_group(self): - return Group.query.filter_by(name='uffd_admin').one_or_none() - - def get_access_group(self): - return Group.query.filter_by(name='uffd_access').one_or_none() - - def get_users_group(self): - return Group.query.filter_by(name='users').one_or_none() - - def get_mail(self): - return Mail.query.filter_by(uid='test').one_or_none() - - def login_as(self, user, ref=None): - # It is currently not possible to login while already logged in as another - # user, so make sure that we are not logged in first - self.client.get(path=url_for('session.logout'), follow_redirects=True) - loginname = None - password = None - if user == 'user': - loginname = 'testuser' - password = 'userpassword' - elif user == 'admin': - loginname = 'testadmin' - password = 'adminpassword' - return self.client.post(path=url_for('session.login', ref=ref), - data={'loginname': loginname, 'password': password}, follow_redirects=True) +class AppTestCase(unittest.TestCase): + DISABLE_SQLITE_MEMORY_DB = False def setUp(self): - # It would be far better to create a minimal app here, but since the - # session module depends on almost everything else, that is not really feasable config = { 'TESTING': True, 'DEBUG': True, @@ -66,9 +34,66 @@ class UffdTestCase(unittest.TestCase): 'MAIL_SKIP_SEND': True, 'SELF_SIGNUP': True, } - + if self.DISABLE_SQLITE_MEMORY_DB: + try: + os.remove('/tmp/uffd-migration-test-db.sqlite3') + except FileNotFoundError: + pass + config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/uffd-migration-test-db.sqlite3' self.app = create_app(config) self.setUpApp() + + def setUpApp(self): + pass + + def tearDown(self): + if self.DISABLE_SQLITE_MEMORY_DB: + try: + os.remove('/tmp/uffd-migration-test-db.sqlite3') + except FileNotFoundError: + pass + +class MigrationTestCase(AppTestCase): + DISABLE_SQLITE_MEMORY_DB = True + + REVISION = None + + def setUp(self): + super().setUp() + self.request_context = self.app.test_request_context() + self.request_context.__enter__() + if self.REVISION: + flask_migrate.upgrade(revision=self.REVISION + '-1') + + def upgrade(self, revision='+1'): + db.session.commit() + flask_migrate.upgrade(revision=revision) + + def downgrade(self, revision='-1'): + db.session.commit() + flask_migrate.downgrade(revision=revision) + + def tearDown(self): + db.session.rollback() + self.request_context.__exit__(None, None, None) + super().tearDown() + +class ModelTestCase(AppTestCase): + def setUp(self): + super().setUp() + self.request_context = self.app.test_request_context() + self.request_context.__enter__() + db.create_all() + db.session.commit() + + def tearDown(self): + db.session.rollback() + self.request_context.__exit__(None, None, None) + super().tearDown() + +class UffdTestCase(AppTestCase): + def setUp(self): + super().setUp() self.client = self.app.test_client() self.client.__enter__() # Just do some request so that we can use url_for @@ -90,11 +115,42 @@ class UffdTestCase(unittest.TestCase): self.setUpDB() db.session.commit() - def setUpApp(self): - pass - def setUpDB(self): pass def tearDown(self): self.client.__exit__(None, None, None) + super().tearDown() + + def get_user(self): + return User.query.filter_by(loginname='testuser').one_or_none() + + def get_admin(self): + return User.query.filter_by(loginname='testadmin').one_or_none() + + def get_admin_group(self): + return Group.query.filter_by(name='uffd_admin').one_or_none() + + def get_access_group(self): + return Group.query.filter_by(name='uffd_access').one_or_none() + + def get_users_group(self): + return Group.query.filter_by(name='users').one_or_none() + + def get_mail(self): + return Mail.query.filter_by(uid='test').one_or_none() + + def login_as(self, user, ref=None): + # It is currently not possible to login while already logged in as another + # user, so make sure that we are not logged in first + self.client.get(path=url_for('session.logout'), follow_redirects=True) + loginname = None + password = None + if user == 'user': + loginname = 'testuser' + password = 'userpassword' + elif user == 'admin': + loginname = 'testadmin' + password = 'adminpassword' + return self.client.post(path=url_for('session.login', ref=ref), + data={'loginname': loginname, 'password': password}, follow_redirects=True) diff --git a/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py b/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py new file mode 100644 index 00000000..e1fe39fe --- /dev/null +++ b/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py @@ -0,0 +1,158 @@ +"""Locking and new ID allocation + +Revision ID: aeb07202a6c8 +Revises: 468995a9c9ee +Create Date: 2022-10-30 13:24:39.864612 + +""" +from alembic import op +import sqlalchemy as sa +from flask import current_app + +# revision identifiers, used by Alembic. +revision = 'aeb07202a6c8' +down_revision = '468995a9c9ee' +branch_labels = None +depends_on = None + +def upgrade(): + conn = op.get_bind() + meta = sa.MetaData(bind=conn) + user_table = sa.Table('user', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_uid', sa.Integer(), nullable=False), + sa.Column('loginname', sa.String(length=32), nullable=False), + sa.Column('displayname', sa.String(length=128), nullable=False), + sa.Column('primary_email_id', sa.Integer(), nullable=False), + sa.Column('recovery_email_id', sa.Integer(), nullable=True), + sa.Column('pwhash', sa.Text(), nullable=True), + sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), + sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), + sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), + sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) + ) + group_table = sa.Table('group', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_gid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=32), nullable=False), + sa.Column('description', sa.String(length=128), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_group')), + sa.UniqueConstraint('name', name=op.f('uq_group_name')), + sa.UniqueConstraint('unix_gid', name=op.f('uq_group_unix_gid')) + ) + + lock_table = op.create_table('lock', + sa.Column('name', sa.String(length=32), nullable=False), + sa.PrimaryKeyConstraint('name', name=op.f('pk_lock')) + ) + conn.execute(sa.insert(lock_table).values(name='uid_allocation')) + conn.execute(sa.insert(lock_table).values(name='gid_allocation')) + + uid_allocation_table = op.create_table('uid_allocation', + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_uid_allocation')) + ) + # Completely block range USER_MAX_UID to max UID currently in use (within + # the UID range) to account for users deleted in the past. + max_user_uid = conn.execute( + sa.select([sa.func.max(user_table.c.unix_uid)]) + .where(user_table.c.unix_uid <= current_app.config['USER_MAX_UID']) + ).scalar() or 0 + if max_user_uid: + for uid in range(current_app.config['USER_MIN_UID'], max_user_uid + 1): + conn.execute(sa.insert(uid_allocation_table).values(id=uid)) + max_service_uid = conn.execute( + sa.select([sa.func.max(user_table.c.unix_uid)]) + .where(user_table.c.unix_uid <= current_app.config['USER_SERVICE_MAX_UID']) + ).scalar() or 0 + if max_service_uid: + for uid in range(current_app.config['USER_SERVICE_MIN_UID'], max_service_uid + 1): + if uid < current_app.config['USER_MIN_UID'] or uid > max_user_uid: + conn.execute(sa.insert(uid_allocation_table).values(id=uid)) + # Also block all UIDs outside of both ranges that are in use + # (just to be sure, there should not be any) + conn.execute(sa.insert(uid_allocation_table).from_select(['id'], + sa.select([user_table.c.unix_uid]).where(sa.and_( + # Out of range for user + sa.or_( + user_table.c.unix_uid < current_app.config['USER_MIN_UID'], + user_table.c.unix_uid > current_app.config['USER_MAX_UID'] + ), + # and out of range for service user + sa.or_( + user_table.c.unix_uid < current_app.config['USER_SERVICE_MIN_UID'], + user_table.c.unix_uid > current_app.config['USER_SERVICE_MAX_UID'] + ), + )) + )) + # Normally we would pass copy_from=user_table, so we don't lose any metadata, + # but this somehow causes an AttributeError (Neither 'ColumnClause' object + # nor 'Comparator' object has an attribute 'copy'). Also, we don't seem to + # lose anything without it. + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_user_unix_uid_uid_allocation'), 'uid_allocation', ['unix_uid'], ['id']) + + gid_allocation_table = op.create_table('gid_allocation', + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_gid_allocation')) + ) + group_table = sa.table('group', sa.column('unix_gid')) + # Completely block range GROUP_MAX_GID to max GID currently in use (within + # the GID range) to account for groups deleted in the past. + max_group_gid = conn.execute( + sa.select([sa.func.max(group_table.c.unix_gid)]) + .where(group_table.c.unix_gid <= current_app.config['GROUP_MAX_GID']) + ).scalar() or 0 + if max_group_gid: + for gid in range(current_app.config['GROUP_MIN_GID'], max_group_gid + 1): + conn.execute(sa.insert(gid_allocation_table).values(id=gid)) + # Also block out-of-range GIDs + conn.execute(sa.insert(gid_allocation_table).from_select(['id'], + sa.select([group_table.c.unix_gid]).where( + sa.or_( + group_table.c.unix_gid < current_app.config['GROUP_MIN_GID'], + group_table.c.unix_gid > current_app.config['GROUP_MAX_GID'] + ) + ) + )) + # See comment on batch_alter_table above + with op.batch_alter_table('group', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_group_unix_gid_gid_allocation'), 'gid_allocation', ['unix_gid'], ['id']) + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + user_table = sa.Table('user', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_uid', sa.Integer(), nullable=False), + sa.Column('loginname', sa.String(length=32), nullable=False), + sa.Column('displayname', sa.String(length=128), nullable=False), + sa.Column('primary_email_id', sa.Integer(), nullable=False), + sa.Column('recovery_email_id', sa.Integer(), nullable=True), + sa.Column('pwhash', sa.Text(), nullable=True), + sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), + sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), + sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), + sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), + sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) + ) + group_table = sa.Table('group', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_gid', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=32), nullable=False), + sa.Column('description', sa.String(length=128), nullable=False), + sa.ForeignKeyConstraint(['unix_gid'], ['gid_allocation.id'], name=op.f('fk_group_unix_gid_gid_allocation')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_group')), + sa.UniqueConstraint('name', name=op.f('uq_group_name')), + sa.UniqueConstraint('unix_gid', name=op.f('uq_group_unix_gid')) + ) + with op.batch_alter_table('group', copy_from=group_table) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_group_unix_gid_gid_allocation'), type_='foreignkey') + with op.batch_alter_table('user', copy_from=user_table) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_user_unix_uid_uid_allocation'), type_='foreignkey') + op.drop_table('gid_allocation') + op.drop_table('uid_allocation') + op.drop_table('lock') diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py index ad417982..5fe24173 100644 --- a/uffd/models/__init__.py +++ b/uffd/models/__init__.py @@ -8,9 +8,9 @@ from .selfservice import PasswordToken from .service import RemailerMode, Service, ServiceUser, get_services from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation from .signup import Signup -from .user import User, UserEmail, Group +from .user import User, UserEmail, Group, IDAllocator, IDRangeExhaustedError, IDAlreadyAllocatedError from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay -from .misc import FeatureFlag +from .misc import FeatureFlag, Lock __all__ = [ 'APIClient', @@ -23,7 +23,7 @@ __all__ = [ 'RemailerMode', 'Service', 'ServiceUser', 'get_services', 'DeviceLoginType', 'DeviceLoginInitiation', 'DeviceLoginConfirmation', 'Signup', - 'User', 'UserEmail', 'Group', + 'User', 'UserEmail', 'Group', 'IDAllocator', 'IDRangeExhaustedError', 'IDAlreadyAllocatedError', 'RatelimitEvent', 'Ratelimit', 'HostRatelimit', 'host_ratelimit', 'format_delay', - 'FeatureFlag', + 'FeatureFlag', 'Lock', ] diff --git a/uffd/models/misc.py b/uffd/models/misc.py index 3fdf954b..3df18be4 100644 --- a/uffd/models/misc.py +++ b/uffd/models/misc.py @@ -39,3 +39,40 @@ class FeatureFlag: func() FeatureFlag.unique_email_addresses = FeatureFlag('unique-email-addresses') + +lock_table = db.Table('lock', + db.Column('name', db.String(32), primary_key=True), +) + +class Lock: + ALL_LOCKS = set() + + def __init__(self, name): + self.name = name + assert name not in self.ALL_LOCKS + self.ALL_LOCKS.add(name) + + def acquire(self): + '''Acquire the lock until the end of the current transaction + + Calling acquire while the specific lock is already held has no effect.''' + if db.engine.name == 'sqlite': + # SQLite does not support with_for_update, but we can lock the whole DB + # with any write operation. So we do a dummy update. + db.session.execute(db.update(lock_table).where(False).values(name=None)) + elif db.engine.name in ('mysql', 'mariadb'): + result = db.session.execute(db.select([lock_table.c.name]).where(lock_table.c.name == self.name).with_for_update()).scalar() + if result is not None: + return + # We add all lock rows with migrations so we should never end up here + raise Exception(f'Lock "{self.name}" is missing') + else: + raise NotImplementedError() + +# Only executed when lock_table is created with db.create/db.create_all (e.g. +# during testing). Otherwise the rows are inserted with migrations. +@db.event.listens_for(lock_table, 'after_create') # pylint: disable=no-member +def insert_lock_rows(target, connection, **kwargs): # pylint: disable=unused-argument + for name in Lock.ALL_LOCKS: + db.session.execute(db.insert(lock_table).values(name=name)) + db.session.commit() diff --git a/uffd/models/user.py b/uffd/models/user.py index 481cac3c..e3fa2f6a 100644 --- a/uffd/models/user.py +++ b/uffd/models/user.py @@ -13,13 +13,95 @@ from uffd.database import db from uffd.remailer import remailer from uffd.utils import token_urlfriendly from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash, HighEntropyPasswordHash -from .misc import FeatureFlag - -# pylint: disable=E1101 -user_groups = db.Table('user_groups', - Column('user_id', Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True), - Column('group_id', Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) -) +from .misc import FeatureFlag, Lock + +class IDRangeExhaustedError(Exception): + pass + +class IDAlreadyAllocatedError(ValueError): + pass + +# Helper class for UID/GID allocation that prevents reuse even if +# users/groups are deleted. +# +# To keep track of formerly used UIDs/GIDs, they are always also added to +# uid/gid allocation tables. Rows in these tables are never deleted. +# User/group tables have foreign key constraints to ensure that there can +# only ever be three cases for a given ID: +# +# 1. The ID was never used (does not exist in either user/group or allocation +# table) +# 2. The ID was used, but the user/group was deleted (it does not exist in +# user/group table, but it exists in the allocation table) +# 3. The ID is in use (it exists in both the user/group and the allocation +# table) +# +# For auto-allocation, there are a few edge cases to consider: +# +# 1. GIDs can be chosen freely in the web interface, e.g. one could easily +# create a group with the last GID in range. +# 2. For UIDs there are two ranges (for regular users and for service users). +# The ranges may either be the same or they may be different but +# non-overlapping. +# 3. ID ranges can be changed (e.g. extended to either side if the old range +# is exhausted). Existing IDs should not change. +# +# The approach we use here is to always auto-allocate the first unused id +# in range. This approach handles the three edge cases well and even behaves +# sanely in unsupported configurations like different but overlapping UID +# ranges. +class IDAllocator: + # pylint completely fails to understand SQLAlchemy's query functions + # pylint: disable=no-member + def __init__(self, name): + self.name = name + self.lock = Lock(f'{name}_allocation') + self.allocation_table = db.Table(f'{name}_allocation', db.Column('id', db.Integer(), primary_key=True)) + + def allocate(self, id): + self.lock.acquire() + result = db.session.execute( + db.select([self.allocation_table.c.id]) + .where(self.allocation_table.c.id == id) + ).scalar() + if result is not None: + raise IDAlreadyAllocatedError(f'Cannot allocate {self.name}: {id} is in use or was used in the past') + db.session.execute(db.insert(self.allocation_table).values(id=id)) + + def auto(self, min_id, max_id): + '''Auto-allocate and return an unused id in range''' + self.lock.acquire() + # We cannot easily iterate through a large range of numbers with generic + # SQL statements looking for unused ids. So to find the first unused id in + # range, we look for the first used id in range that is followed by an + # unused id. This does not work if there are no used ids in range (returns + # NULL) or if min_id is unused (returns higher id while it should return + # min_id). To fix this we also check if min_id is used or not. + tmp = db.aliased(self.allocation_table) + first_unused_id = db.session.execute( + db.select([db.func.min(self.allocation_table.c.id + 1)]) + .where(self.allocation_table.c.id >= min_id) + .where(db.not_(db.exists().where(tmp.c.id == self.allocation_table.c.id + 1))) + ).scalar() + min_id_used = db.session.execute( + db.select([db.exists() + .where(self.allocation_table.c.id == min_id)]) + ).scalar() + if not min_id_used: + first_unused_id = min_id + if first_unused_id > max_id: + raise IDRangeExhaustedError(f'Cannot auto-allocate {self.name}: Range is exhausted') + db.session.execute(db.insert(self.allocation_table).values(id=first_unused_id)) + return first_unused_id + +def user_unix_uid_default(context): + if context.get_current_parameters()['is_service_user']: + min_uid = current_app.config['USER_SERVICE_MIN_UID'] + max_uid = current_app.config['USER_SERVICE_MAX_UID'] + else: + min_uid = current_app.config['USER_MIN_UID'] + max_uid = current_app.config['USER_MAX_UID'] + return User.unix_uid_allocator.auto(min_uid, max_uid) class User(db.Model): # Allows 8 to 256 ASCII letters (lower and upper case), digits, spaces and @@ -38,8 +120,16 @@ class User(db.Model): __tablename__ = 'user' id = Column(Integer(), primary_key=True, autoincrement=True) - # Default is set in event handler below - unix_uid = Column(Integer(), unique=True, nullable=False) + + unix_uid_allocator = IDAllocator('uid') + unix_uid = Column(Integer(), ForeignKey('uid_allocation.id'), unique=True, nullable=False, default=user_unix_uid_default) + + @validates('unix_uid') + def validate_unix_uid(self, key, value): # pylint: disable=unused-argument + if self.unix_uid != value and value is not None: + self.unix_uid_allocator.allocate(value) + return value + loginname = Column(String(32), unique=True, nullable=False) displayname = Column(String(128), nullable=False) @@ -261,7 +351,7 @@ class UserEmail(db.Model): return self.verification_expires < datetime.datetime.utcnow() def finish_verification(self, secret): - # pylint: disable=using-constant-test + # pylint: disable=using-constant-test,no-member if self.verification_expired: return False if not self.verification_secret.verify(secret): @@ -280,41 +370,28 @@ def enable_unique_email_addresses(): def disable_unique_email_addresses(): UserEmail.query.update({UserEmail.enable_strict_constraints: None}) -def next_id_expr(column, min_value, max_value): - # db.func.max(column) + 1: highest used value in range + 1, NULL if no values in range - # db.func.min(..., max_value): clip to range - # db.func.coalesce(..., min_value): if NULL use min_value - # if range is exhausted, evaluates to max_value that violates the UNIQUE constraint - return db.select([db.func.coalesce(db.func.min(db.func.max(column) + 1, max_value), min_value)])\ - .where(column >= min_value)\ - .where(column <= max_value) - -# Emulates the behaviour of Column.default. We cannot use a static SQL -# expression like we do for Group.unix_gid, because we need context -# information. We also cannot set Column.default to a callable, because -# SQLAlchemy always treats the return value as a literal value and does -# not allow SQL expressions. -@db.event.listens_for(User, 'before_insert') -def set_default_unix_uid(mapper, connect, target): - # pylint: disable=unused-argument - if target.unix_uid is not None: - return - if target.is_service_user: - min_uid = current_app.config['USER_SERVICE_MIN_UID'] - max_uid = current_app.config['USER_SERVICE_MAX_UID'] - else: - min_uid = current_app.config['USER_MIN_UID'] - max_uid = current_app.config['USER_MAX_UID'] - target.unix_uid = next_id_expr(User.unix_uid, min_uid, max_uid) +# pylint: disable=E1101 +user_groups = db.Table('user_groups', + Column('user_id', Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True), + Column('group_id', Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True) +) -group_table = db.table('group', db.column('unix_gid')) -min_gid = db.bindparam('min_gid', unique=True, callable_=lambda: current_app.config['GROUP_MIN_GID'], type_=db.Integer) -max_gid = db.bindparam('max_gid', unique=True, callable_=lambda: current_app.config['GROUP_MAX_GID'], type_=db.Integer) +def group_unix_gid_default(): + return Group.unix_gid_allocator.auto(current_app.config['GROUP_MIN_GID'], current_app.config['GROUP_MAX_GID']) class Group(db.Model): __tablename__ = 'group' id = Column(Integer(), primary_key=True, autoincrement=True) - unix_gid = Column(Integer(), unique=True, nullable=False, default=next_id_expr(group_table.c.unix_gid, min_gid, max_gid)) + + unix_gid_allocator = IDAllocator('gid') + unix_gid = Column(Integer(), ForeignKey('gid_allocation.id'), unique=True, nullable=False, default=group_unix_gid_default) + + @validates('unix_gid') + def validate_unix_gid(self, key, value): # pylint: disable=unused-argument + if self.unix_gid != value and value is not None: + self.unix_gid_allocator.allocate(value) + return value + name = Column(String(32), unique=True, nullable=False) description = Column(String(128), nullable=False, default='') members = relationship('User', secondary='user_groups', back_populates='groups') diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index e20677fe03a9318c965249d14437600f175bd9cd..f7010b8fa6548e32d0a8e5ebeb97cc081ca8d9c0 100644 GIT binary patch delta 6650 zcmaFAgX!{4ruutAEK?a67#JoqGBC(6FfeT40r3#nN0NbopMinFSCWB2n1O*IR+52% zje&uoP?CYchk=2iLXv?2q~)k20|OTW1H&aK|E?qhgBAk=!z-w`s1ySOF9QREf)oRT z5Ca2)o)p-;dIm=bgCSIkfkBFafgw|hfq|WYfuTi;fkA|UfuTo=fq|QWfnlK(14A_f z1H)P=1_nh21_n)Oh&pd+h`L~D1_mVt28Il21_n6>28M2F1_pZu28LDA3=CEvhe$Ip z6f!U{sLDX(r^_%fI5N~TFl>{7xa@}v14A?e0|T2Z1H*C#28JYA1_pNq1_mxU28LP& z28JLxhy@qq7#PGE7#Kc7X>NH21~CQ(24#5$1``Gb1}k}pNAl$v7~B{b7`o&c7$g}O z7%oE9y_ScBD3byMgGD_91A~wPB<Q>qAVC|Zz`&pa3Ti07NdcmvQvu?mnF<UHtPBhc zHx(EdVi_11?kg}ba56A3*eXIi;;smh4^?DfU}0cjh*o4^Fl1n0NKk~BGf9zwL6(7m zVUZ#OL%k>i1H%DD1_o&c28L^j3=Dz{3=E&3G>Z}g0|x^GgP0P;hw@4gb!JN7xMK)X zVqoB7U|>j7f<#fN62v2oP<=g04B)gfRSDv-IZ6x+Ap2G+LDI|tC5C!%+@4i}#Nkb- z#5*YcTL}_$9Lfv~nhXpKO3Dz6eUup(EI~d}hFCC58Dii<Wk}jsrwnoU1*rH<Wr)Sk zlo=S*85kJ;C_|!9MWr4RS2ijT1>q_XALT;n8Wjcx0Z?L6fu!0wDv+RCufo7!&A`BL zRs|9=oT`u@R#1gFI7k&@L5?cK{1#OP27LwwhWV-x{pVE~7!(*77~a;aLgI=~4Pu~( z8YGSb)F7#Qf*J!uCIbV*3@D#T9pW=Cb%@V})gca)QisH?wmKv#tkoG9m>C!t+|?Nv zbQu^JywxF5SE&w3{ZrK;_SJ7uhs4bXsKNiBe0B|pxTpps=#@1f7W!&HLMTLofkByp zfgxFgfx&@+fuUW4f#E6x1H%ao28Id-28MZ>3=9()7#PI0AQrFGg6Kc01<`j#3+&)} zh7Vd00~xfz0u21xkZdNe&A_mkfq}sZD*jCy5+ZCmkRayMfdsLJ4kS%D=s?myhz=w~ z5_KRBFVcY&Y;&OMcIrT)>?~Nko`K<s4#Z`Qx{#pb(1jQ%q6=}DhAt#gnd>qzXfQA^ zMCwAaXOk|(pyj%dDBGtCao|5)h<R*!3?M%-@ajRL*isLY9qsiX+0{jlfk6S3|NZnB z7*rV;81nTXKANfriKDf83=A#|3=C)VAgNYR9};wy`Va$S^�USsx;wrw=h_nm!~k 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 SEVZaSH7_N#WV3Wyh#3ILnN*(u delta 6572 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 MkZ!@vjcGw<08bZ0;s5{u diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 4088baad..52d52102 100644 --- a/uffd/translations/de/LC_MESSAGES/messages.po +++ b/uffd/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-10-25 22:00+0200\n" +"POT-Creation-Date: 2022-11-01 00:38+0100\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -98,7 +98,7 @@ msgstr "Falsches Passwort" msgid "Login name or e-mail address is already in use" msgstr "Der Anmeldename oder die E-Mail-Adresse wird bereits verwendet" -#: uffd/models/user.py:37 +#: uffd/models/user.py:46 #, python-format msgid "" "At least %(minlen)d and at most %(maxlen)d characters. Only letters, " @@ -1633,23 +1633,27 @@ msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)" msgid "Groups" msgstr "Gruppen" -#: uffd/views/group.py:41 +#: uffd/views/group.py:42 +msgid "GID is already in use or was used in the past" +msgstr "GID wird oder wurde bereits verwendet" + +#: uffd/views/group.py:45 msgid "Invalid name" msgstr "Ungültiger Name" -#: uffd/views/group.py:52 +#: uffd/views/group.py:56 msgid "Group with this name or id already exists" msgstr "Gruppe mit diesem Namen oder dieser ID existiert bereits" -#: uffd/views/group.py:57 +#: uffd/views/group.py:61 msgid "Group created" msgstr "Gruppe erstellt" -#: uffd/views/group.py:59 +#: uffd/views/group.py:63 msgid "Group updated" msgstr "Gruppe aktualisiert" -#: uffd/views/group.py:68 +#: uffd/views/group.py:72 msgid "Deleted group" msgstr "Gruppe gelöscht" diff --git a/uffd/views/group.py b/uffd/views/group.py index d70751ef..ec759632 100644 --- a/uffd/views/group.py +++ b/uffd/views/group.py @@ -36,7 +36,11 @@ def update(id=None): if id is None: group = Group() if request.form['unix_gid']: - group.unix_gid = int(request.form['unix_gid']) + try: + group.unix_gid = int(request.form['unix_gid']) + except ValueError: + flash(_('GID is already in use or was used in the past')) + return render_template('group/show.html', group=group), 400 if not group.set_name(request.form['name']): flash(_('Invalid name')) return render_template('group/show.html', group=group), 400 -- GitLab