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
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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