From 0d870ee101980ac72fd3fe9538687ec789333ead Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Sun, 22 Oct 2023 22:18:31 +0200
Subject: [PATCH] Debian Bookworm support

- Add CI tests for Bookworm
- Disable pylint deprecation warnings for crypt
- Mitigate Flask changes that broke a few tests
- Set create_constraint=True for Booleans/Enums to mitigate SQLAlchemy changes
- Mitigate new Alembic CHECK constraint behaviour in batch mode
---
 .gitlab-ci.yml                                | 99 +++++++++++++++----
 README.md                                     |  4 +-
 tests/views/test_selfservice.py               | 50 +++++-----
 .../versions/23293f32b503_deactivate_users.py | 28 +++---
 .../versions/2b68f688bec1_remailer_v2.py      | 26 ++---
 .../468995a9c9ee_unique_email_addresses.py    | 14 +--
 .../versions/54b2413586fd_invite_pk_change.py | 16 +--
 ...31c_remailer_setting_and_api_permission.py | 28 +++---
 .../versions/878b25c4fae7_ldap_to_db.py       | 36 +++----
 .../a29870f95175_initial_migration.py         | 10 +-
 .../a594d3b3e05b_added_role_locked.py         | 16 ++-
 ...14_fix_not_null_on_role_groups_group_id.py |  4 +-
 .../versions/a8c6b6e91c28_device_login.py     |  2 +-
 ...07202a6c8_locking_and_new_id_allocation.py |  4 +-
 ...ed_password_hashing_for_user_and_signup.py |  4 +-
 .../aff5f350dcdf_added_role_is_default.py     | 18 +++-
 .../b273d7fdaa25_multiple_email_addresses.py  |  8 +-
 ...ca3675_added_api_permission_for_metrics.py | 20 ++--
 ...ac9db_move_api_and_oauth2_clients_to_db.py | 12 +--
 ...dded_rolegroup_requires_mfa_and_cleanup.py |  4 +-
 .../cbca20cf64d9_constraint_name_fixes.py     | 10 +-
 ...b733ec856_per_service_email_preferences.py |  8 +-
 .../e249233e2a31_remailer_mode_overwrite.py   |  4 +-
 uffd/models/api.py                            | 10 +-
 uffd/models/invite.py                         |  8 +-
 uffd/models/mfa.py                            |  5 +-
 uffd/models/role.py                           |  6 +-
 uffd/models/service.py                        | 10 +-
 uffd/models/session.py                        |  2 +-
 uffd/models/user.py                           |  8 +-
 uffd/password_hash.py                         |  2 +-
 31 files changed, 279 insertions(+), 197 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index d70dc575..b11a4c23 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -75,33 +75,30 @@ linter:bullseye:
     reports:
       codequality: codeclimate.json
 
+linter:bookworm:
+  image: registry.git.cccv.de/uffd/docker-images/bookworm
+  stage: test
+  needs: []
+  script:
+  - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages!
+  - python3 -m pylint --output-format=pylint_gitlab.GitlabCodeClimateReporter:codeclimate.json,pylint_gitlab.GitlabPagesHtmlReporter:pylint.html,colorized uffd
+  artifacts:
+    when: always
+    paths:
+    - pylint.html
+    reports:
+      codequality: codeclimate.json
+
 tests:buster:sqlite:
   image: registry.git.cccv.de/uffd/docker-images/buster
   stage: test
   needs: []
   script:
-  - rm -rf pages
-  - mkdir -p pages
-  - cp -r uffd/static pages/static
-  - DUMP_PAGES=pages python3-coverage run --include 'uffd/*.py' -m pytest --junitxml=report.xml || touch failed
-  - sed -i -e 's/href="\/static\//href=".\/static\//g' -e 's/src="\/static\//src=".\/static\//g' pages/*.html || true
-  - python3-coverage report -m
-  - python3-coverage html
-  - python3-coverage xml
-  - test ! -e failed
+  - python3 -m pytest --junitxml=report.xml
   artifacts:
     when: always
-    paths:
-    - htmlcov/index.html
-    - htmlcov
-    - pages
-    expose_as: 'Coverage Report'
     reports:
-      coverage_report:
-        coverage_format: cobertura
-        path: coverage.xml
       junit: report.xml
-  coverage: '/^TOTAL.*\s+(\d+\%)$/'
 
 tests:buster:mysql:
   image: registry.git.cccv.de/uffd/docker-images/buster
@@ -138,10 +135,50 @@ tests:bullseye:mysql:
     reports:
       junit: report.xml
 
+tests:bookworm:sqlite:
+  image: registry.git.cccv.de/uffd/docker-images/bookworm
+  stage: test
+  needs: []
+  script:
+  - rm -rf pages
+  - mkdir -p pages
+  - cp -r uffd/static pages/static
+  - DUMP_PAGES=pages python3-coverage run --include 'uffd/*.py' -m pytest --junitxml=report.xml || touch failed
+  - sed -i -e 's/href="\/static\//href=".\/static\//g' -e 's/src="\/static\//src=".\/static\//g' pages/*.html || true
+  - python3-coverage report -m
+  - python3-coverage html
+  - python3-coverage xml
+  - test ! -e failed
+  artifacts:
+    when: always
+    paths:
+    - htmlcov/index.html
+    - htmlcov
+    - pages
+    expose_as: 'Coverage Report'
+    reports:
+      coverage_report:
+        coverage_format: cobertura
+        path: coverage.xml
+      junit: report.xml
+  coverage: '/^TOTAL.*\s+(\d+\%)$/'
+
+tests:bookworm:mysql:
+  image: registry.git.cccv.de/uffd/docker-images/bookworm
+  stage: test
+  needs: []
+  script:
+  - service mariadb start
+  - TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml
+  artifacts:
+    when: always
+    reports:
+      junit: report.xml
+
 html5validator:
   stage: test
   needs:
-  - job: tests:buster:sqlite
+  - job: tests:bookworm:sqlite
   script:
   - html5validator --root pages 2>&1 | tee html5validator.log
   artifacts:
@@ -177,6 +214,14 @@ test:package:pip:bullseye:
   script:
   - pip3 install dist/*.tar.gz
 
+test:package:pip:bookworm:
+  image: registry.git.cccv.de/uffd/docker-images/bookworm
+  stage: test
+  needs:
+  - job: build:pip
+  script:
+  - pip3 install dist/*.tar.gz
+
 # Since we want to test if the package installs correctly on a fresh Debian
 # install (has correct dependencies, etc.), we don't use uffd/docker-images
 # here
@@ -210,6 +255,21 @@ test:package:apt:bullseye:
   - uffd-admin routes
   - curl -Lv 127.0.0.1:5000
 
+test:package:apt:bookworm:
+  image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/debian:bookworm
+  stage: test
+  needs:
+  - job: build:apt
+  before_script: []
+  script:
+  - apt -y update
+  - apt -y install curl ./*.deb
+  - service uwsgi start uffd || ( service uwsgi status uffd ; sleep 15; cat /var/log/uwsgi/app/uffd.log; )
+  - echo "server { listen 127.0.0.1:5000 default_server;  include /etc/uffd/nginx.include.conf; }" > /etc/nginx/sites-enabled/uffd.ini
+  - service nginx start || ( service nginx status; nginx -t; exit 1; )
+  - uffd-admin routes
+  - curl -Lv 127.0.0.1:5000
+
 .publish:
   stage: deploy
   rules:
@@ -234,5 +294,6 @@ publish:apt:
   - echo Update published repo for all distros
   - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/buster"'
   - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/bullseye"'
+  - 'curl --user "${APTLY_API_USER}:${APTLY_API_PW}" -X PUT -H "Content-Type: application/json" --data "{ }" "${APT_API_URL}/api/publish/uffd/bookworm"'
   dependencies:
   - build:apt
diff --git a/README.md b/README.md
index f9e79f31..f1cd4f30 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Please note that we refer to Debian packages here and **not** pip packages.
 - python3-itsdangerous (also a dependency of python3-flask)
 - python3-mysqldb or python3-pymysql for MariaDB support
 
-Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Buster or Bullseye.
+Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Bookworm, Bullseye or Buster.
 For development, you can also use virtualenv with the supplied `requirements.txt`.
 
 ## Development
@@ -57,7 +57,7 @@ The dependencies of the pip package roughly represent the versions shipped by De
 We do not keep them updated and we do not test the pip package!
 The pip package only exists for local testing/development and to help build the Debian package.
 
-We provide packages for Debian stable and oldstable (currently Bullseye and Buster).
+We provide packages for Debian stable, oldstable and oldoldstable (currently Bookworm, Bullseye and Buster).
 Since all dependencies are available in the official package mirrors, you will get security updates for everything but uffd itself from Debian.
 
 To install uffd on Debian Bullseye, add our package mirror to `/etc/sources.list`:
diff --git a/tests/views/test_selfservice.py b/tests/views/test_selfservice.py
index b5b20241..2396b77f 100644
--- a/tests/views/test_selfservice.py
+++ b/tests/views/test_selfservice.py
@@ -21,25 +21,23 @@ class TestSelfservice(UffdTestCase):
 
 	def test_update_displayname(self):
 		self.login_as('user')
-		user = request.user
 		r = self.client.post(path=url_for('selfservice.update_profile'),
 			data={'displayname': 'New Display Name'},
 			follow_redirects=True)
 		dump('update_displayname', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertEqual(_user.displayname, 'New Display Name')
+		user = self.get_user()
+		self.assertEqual(user.displayname, 'New Display Name')
 
 	def test_update_displayname_invalid(self):
 		self.login_as('user')
-		user = request.user
 		r = self.client.post(path=url_for('selfservice.update_profile'),
 			data={'displayname': ''},
 			follow_redirects=True)
 		dump('update_displayname_invalid', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertNotEqual(_user.displayname, '')
+		user = self.get_user()
+		self.assertNotEqual(user.displayname, '')
 
 	def test_add_email(self):
 		self.login_as('user')
@@ -52,7 +50,7 @@ class TestSelfservice(UffdTestCase):
 		m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content()))
 		email_id, secret = m.groups()
 		email = UserEmail.query.get(email_id)
-		self.assertEqual(email.user, request.user)
+		self.assertEqual(email.user.id, request.user.id)
 		self.assertEqual(email.address, 'new@example.com')
 		self.assertFalse(email.verified)
 		self.assertFalse(email.verification_expired)
@@ -164,7 +162,7 @@ class TestSelfservice(UffdTestCase):
 		m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content()))
 		email_id, secret = m.groups()
 		email = UserEmail.query.get(email_id)
-		self.assertEqual(email.user, request.user)
+		self.assertEqual(email.user.id, request.user.id)
 		self.assertEqual(email.address, 'new@example.com')
 		self.assertFalse(email.verified)
 		self.assertFalse(email.verification_expired)
@@ -245,17 +243,20 @@ class TestSelfservice(UffdTestCase):
 			r = self.client.post(path=url_for('selfservice.update_email_preferences'),
 				data={'primary_email': str(email_id), 'recovery_email': 'primary'},
 				follow_redirects=True)
-		self.assertEqual(self.get_user().primary_email.address, 'test@example.com')
+		with self.app.test_request_context():
+			self.assertEqual(self.get_user().primary_email.address, 'test@example.com')
 		with self.assertRaises(Exception):
 			r = self.client.post(path=url_for('selfservice.update_email_preferences'),
 				data={'primary_email': str(old_email_id), 'recovery_email': str(email_id)},
 				follow_redirects=True)
-		self.assertIsNone(self.get_user().recovery_email)
+		with self.app.test_request_context():
+			self.assertIsNone(self.get_user().recovery_email)
 		with self.assertRaises(Exception):
 			r = self.client.post(path=url_for('selfservice.update_email_preferences'),
 				data={'primary_email': str(old_email_id), 'recovery_email': 'primary', f'service_{service_id}_email': str(email_id)},
 				follow_redirects=True)
-		self.assertIsNone(ServiceUser.query.get((service_id, user_id)).service_email)
+		with self.app.test_request_context():
+			self.assertIsNone(ServiceUser.query.get((service_id, user_id)).service_email)
 
 	def test_update_email_preferences_invalid(self):
 		self.login_as('user')
@@ -284,52 +285,47 @@ class TestSelfservice(UffdTestCase):
 
 	def test_change_password(self):
 		self.login_as('user')
-		user = request.user
 		r = self.client.post(path=url_for('selfservice.change_password'),
 			data={'password1': 'newpassword', 'password2': 'newpassword'},
 			follow_redirects=True)
 		dump('change_password', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertTrue(_user.password.verify('newpassword'))
+		self.assertTrue(self.get_user().password.verify('newpassword'))
 
 	def test_change_password_invalid(self):
 		self.login_as('user')
-		user = request.user
 		r = self.client.post(path=url_for('selfservice.change_password'),
 			data={'password1': 'shortpw', 'password2': 'shortpw'},
 			follow_redirects=True)
 		dump('change_password_invalid', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertFalse(_user.password.verify('shortpw'))
-		self.assertTrue(_user.password.verify('userpassword'))
+		user = self.get_user()
+		self.assertFalse(user.password.verify('shortpw'))
+		self.assertTrue(user.password.verify('userpassword'))
 
 	# Regression test for #100 (login not possible if password contains character disallowed by SASLprep)
 	def test_change_password_samlprep_invalid(self):
 		self.login_as('user')
-		user = request.user
 		r = self.client.post(path=url_for('selfservice.change_password'),
 			data={'password1': 'shortpw\n', 'password2': 'shortpw\n'},
 			follow_redirects=True)
 		dump('change_password_samlprep_invalid', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertFalse(_user.password.verify('shortpw\n'))
-		self.assertTrue(_user.password.verify('userpassword'))
+		user = self.get_user()
+		self.assertFalse(user.password.verify('shortpw\n'))
+		self.assertTrue(user.password.verify('userpassword'))
 
 	def test_change_password_mismatch(self):
 		self.login_as('user')
-		user = request.user
 		r = self.client.post(path=url_for('selfservice.change_password'),
 			data={'password1': 'newpassword1', 'password2': 'newpassword2'},
 			follow_redirects=True)
 		dump('change_password_mismatch', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertFalse(_user.password.verify('newpassword1'))
-		self.assertFalse(_user.password.verify('newpassword2'))
-		self.assertTrue(_user.password.verify('userpassword'))
+		user = self.get_user()
+		self.assertFalse(user.password.verify('newpassword1'))
+		self.assertFalse(user.password.verify('newpassword2'))
+		self.assertTrue(user.password.verify('userpassword'))
 
 	def test_leave_role(self):
 		baserole = Role(name='baserole', is_default=True)
diff --git a/uffd/migrations/versions/23293f32b503_deactivate_users.py b/uffd/migrations/versions/23293f32b503_deactivate_users.py
index c25dbf6c..d6b9c9c2 100644
--- a/uffd/migrations/versions/23293f32b503_deactivate_users.py
+++ b/uffd/migrations/versions/23293f32b503_deactivate_users.py
@@ -16,15 +16,15 @@ depends_on = None
 def upgrade():
 	meta = sa.MetaData(bind=op.get_bind())
 	with op.batch_alter_table('service', schema=None) as batch_op:
-		batch_op.add_column(sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false()))
+		batch_op.add_column(sa.Column('hide_deactivated_users', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()))
 	service = sa.Table('service', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
 		sa.Column('name', sa.String(length=255), nullable=False),
-		sa.Column('limit_access', sa.Boolean(), nullable=False),
+		sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
 		sa.Column('access_group_id', sa.Integer(), nullable=True),
-		sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False),
-		sa.Column('enable_email_preferences', sa.Boolean(), nullable=False),
-		sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false()),
+		sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False),
+		sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('hide_deactivated_users', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()),
 		sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
 		sa.UniqueConstraint('name', name=op.f('uq_service_name'))
@@ -32,7 +32,7 @@ def upgrade():
 	with op.batch_alter_table('service', copy_from=service) as batch_op:
 		batch_op.alter_column('hide_deactivated_users', server_default=None)
 	with op.batch_alter_table('user', schema=None) as batch_op:
-		batch_op.add_column(sa.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false()))
+		batch_op.add_column(sa.Column('is_deactivated', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()))
 	user = sa.Table('user', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
 		sa.Column('unix_uid', sa.Integer(), nullable=False),
@@ -41,8 +41,8 @@ def upgrade():
 		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.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false()),
+		sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('is_deactivated', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.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')),
@@ -63,8 +63,8 @@ def downgrade():
 		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.Column('is_deactivated', sa.Boolean(), nullable=False),
+		sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('is_deactivated', sa.Boolean(create_constraint=True), 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')),
@@ -77,11 +77,11 @@ def downgrade():
 	service = sa.Table('service', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
 		sa.Column('name', sa.String(length=255), nullable=False),
-		sa.Column('limit_access', sa.Boolean(), nullable=False),
+		sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
 		sa.Column('access_group_id', sa.Integer(), nullable=True),
-		sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False),
-		sa.Column('enable_email_preferences', sa.Boolean(), nullable=False),
-		sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False),
+		sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False),
+		sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('hide_deactivated_users', sa.Boolean(create_constraint=True), nullable=False),
 		sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
 		sa.UniqueConstraint('name', name=op.f('uq_service_name'))
diff --git a/uffd/migrations/versions/2b68f688bec1_remailer_v2.py b/uffd/migrations/versions/2b68f688bec1_remailer_v2.py
index bdf95ccc..d48c3af1 100644
--- a/uffd/migrations/versions/2b68f688bec1_remailer_v2.py
+++ b/uffd/migrations/versions/2b68f688bec1_remailer_v2.py
@@ -15,22 +15,22 @@ depends_on = None
 
 def upgrade():
 	with op.batch_alter_table('service', schema=None) as batch_op:
-		batch_op.add_column(sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False, server_default='DISABLED'))
+		batch_op.add_column(sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False, server_default='DISABLED'))
 	service = sa.table('service',
 		sa.column('id', sa.Integer),
-		sa.column('use_remailer', sa.Boolean),
-		sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode')),
+		sa.column('use_remailer', sa.Boolean(create_constraint=True)),
+		sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode')),
 	)
 	op.execute(service.update().values(remailer_mode='ENABLED_V1').where(service.c.use_remailer))
 	meta = sa.MetaData(bind=op.get_bind())
 	service = sa.Table('service', meta,
     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
     sa.Column('name', sa.String(length=255), nullable=False),
-    sa.Column('limit_access', sa.Boolean(), nullable=False),
+    sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
     sa.Column('access_group_id', sa.Integer(), nullable=True),
-    sa.Column('use_remailer', sa.Boolean(), nullable=False),
-    sa.Column('enable_email_preferences', sa.Boolean(), nullable=False),
-    sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False, server_default='DISABLED'),
+    sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False, server_default='DISABLED'),
     sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
     sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
     sa.UniqueConstraint('name', name=op.f('uq_service_name'))
@@ -44,19 +44,19 @@ def downgrade():
 		batch_op.add_column(sa.Column('use_remailer', sa.BOOLEAN(), nullable=False, server_default=sa.false()))
 	service = sa.table('service',
 		sa.column('id', sa.Integer),
-		sa.column('use_remailer', sa.Boolean),
-		sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode')),
+		sa.column('use_remailer', sa.Boolean(create_constraint=True)),
+		sa.column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode')),
 	)
 	op.execute(service.update().values(use_remailer=sa.true()).where(service.c.remailer_mode != 'DISABLED'))
 	meta = sa.MetaData(bind=op.get_bind())
 	service = sa.Table('service', meta,
     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
     sa.Column('name', sa.String(length=255), nullable=False),
-    sa.Column('limit_access', sa.Boolean(), nullable=False),
+    sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
     sa.Column('access_group_id', sa.Integer(), nullable=True),
-    sa.Column('use_remailer', sa.Boolean(), nullable=False, server_default=sa.false()),
-    sa.Column('enable_email_preferences', sa.Boolean(), nullable=False),
-    sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False),
+    sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()),
+    sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=False),
     sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
     sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
     sa.UniqueConstraint('name', name=op.f('uq_service_name'))
diff --git a/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py b/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py
index 2c776195..b9bc7058 100644
--- a/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py
+++ b/uffd/migrations/versions/468995a9c9ee_unique_email_addresses.py
@@ -37,16 +37,16 @@ def iter_rows_paged(table, pk='id', limit=1000):
 def upgrade():
 	with op.batch_alter_table('user_email', schema=None) as batch_op:
 		batch_op.add_column(sa.Column('address_normalized', sa.String(length=128), nullable=True))
-		batch_op.add_column(sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True))
-		batch_op.alter_column('verified', existing_type=sa.Boolean(), nullable=True)
+		batch_op.add_column(sa.Column('enable_strict_constraints', sa.Boolean(create_constraint=True), nullable=True))
+		batch_op.alter_column('verified', existing_type=sa.Boolean(create_constraint=True), nullable=True)
 	meta = sa.MetaData(bind=op.get_bind())
 	user_email_table = sa.Table('user_email', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
 		sa.Column('user_id', sa.Integer(), nullable=True),
 		sa.Column('address', sa.String(length=128), nullable=False),
 		sa.Column('address_normalized', sa.String(length=128), nullable=True),
-		sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True),
-		sa.Column('verified', sa.Boolean(), nullable=True),
+		sa.Column('enable_strict_constraints', sa.Boolean(create_constraint=True), nullable=True),
+		sa.Column('verified', sa.Boolean(create_constraint=True), nullable=True),
 		sa.Column('verification_legacy_id', sa.Integer(), nullable=True),
 		sa.Column('verification_secret', sa.Text(), nullable=True),
 		sa.Column('verification_expires', sa.DateTime(), nullable=True),
@@ -81,8 +81,8 @@ def downgrade():
 		sa.Column('user_id', sa.Integer(), nullable=True),
 		sa.Column('address', sa.String(length=128), nullable=False),
 		sa.Column('address_normalized', sa.String(length=128), nullable=False),
-		sa.Column('enable_strict_constraints', sa.Boolean(), nullable=True),
-		sa.Column('verified', sa.Boolean(), nullable=True),
+		sa.Column('enable_strict_constraints', sa.Boolean(create_constraint=True), nullable=True),
+		sa.Column('verified', sa.Boolean(create_constraint=True), nullable=True),
 		sa.Column('verification_legacy_id', sa.Integer(), nullable=True),
 		sa.Column('verification_secret', sa.Text(), nullable=True),
 		sa.Column('verification_expires', sa.DateTime(), nullable=True),
@@ -96,7 +96,7 @@ def downgrade():
 	with op.batch_alter_table('user_email', copy_from=user_email_table) as batch_op:
 		batch_op.drop_constraint('uq_user_email_user_id_address_normalized', type_='unique')
 		batch_op.drop_constraint('uq_user_email_address_normalized_verified', type_='unique')
-		batch_op.alter_column('verified', existing_type=sa.Boolean(), nullable=False)
+		batch_op.alter_column('verified', existing_type=sa.Boolean(create_constraint=True), nullable=False)
 		batch_op.drop_column('enable_strict_constraints')
 		batch_op.drop_column('address_normalized')
 	op.drop_table('feature_flag')
diff --git a/uffd/migrations/versions/54b2413586fd_invite_pk_change.py b/uffd/migrations/versions/54b2413586fd_invite_pk_change.py
index a59e2ca6..75d60783 100644
--- a/uffd/migrations/versions/54b2413586fd_invite_pk_change.py
+++ b/uffd/migrations/versions/54b2413586fd_invite_pk_change.py
@@ -38,10 +38,10 @@ def upgrade():
 		sa.Column('token', sa.String(length=128), nullable=False),
 		sa.Column('created', sa.DateTime(), nullable=False),
 		sa.Column('valid_until', sa.DateTime(), nullable=False),
-		sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False),
-		sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False),
-		sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False),
-		sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False),
+		sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False),
+		sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False),
+		sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False),
+		sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False),
 		sa.PrimaryKeyConstraint('token', name=op.f('pk_invite'))
 	)
 	with op.batch_alter_table('invite_grant', schema=None) as batch_op:
@@ -115,10 +115,10 @@ def downgrade():
 		sa.Column('token', sa.String(length=128), nullable=False),
 		sa.Column('created', sa.DateTime(), nullable=False),
 		sa.Column('valid_until', sa.DateTime(), nullable=False),
-		sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False),
-		sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False),
-		sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False),
-		sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False),
+		sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False),
+		sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False),
+		sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False),
+		sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_invite')),
 		sa.UniqueConstraint('token', name=op.f('uq_invite_token'))
 	)
diff --git a/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py
index 8933610d..3e63561b 100644
--- a/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py
+++ b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py
@@ -21,19 +21,19 @@ def upgrade():
 	# table. To keep the resulting database consistent, we remove the
 	# server_default afterwards.
 	with op.batch_alter_table('api_client') as batch_op:
-		batch_op.add_column(sa.Column('perm_remailer', sa.Boolean(), nullable=False, server_default=sa.false()))
+		batch_op.add_column(sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()))
 	with op.batch_alter_table('service') as batch_op:
-		batch_op.add_column(sa.Column('use_remailer', sa.Boolean(), nullable=False, server_default=sa.false()))
+		batch_op.add_column(sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()))
 	meta = sa.MetaData(bind=op.get_bind())
 	api_client = sa.Table('api_client', meta,
     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
     sa.Column('service_id', sa.Integer(), nullable=False),
     sa.Column('auth_username', sa.String(length=40), nullable=False),
     sa.Column('auth_password', sa.Text(), nullable=False),
-    sa.Column('perm_users', sa.Boolean(), nullable=False),
-    sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
-    sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
-    sa.Column('perm_remailer', sa.Boolean(), nullable=False, server_default=sa.false()),
+    sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()),
     sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
     sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
     sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
@@ -41,9 +41,9 @@ def upgrade():
 	service = sa.Table('service', meta,
     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
     sa.Column('name', sa.String(length=255), nullable=False),
-    sa.Column('limit_access', sa.Boolean(), nullable=False),
+    sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
     sa.Column('access_group_id', sa.Integer(), nullable=True),
-    sa.Column('use_remailer', sa.Boolean(), nullable=False, server_default=sa.false()),
+    sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()),
     sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
     sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
     sa.UniqueConstraint('name', name=op.f('uq_service_name'))
@@ -60,10 +60,10 @@ def downgrade():
     sa.Column('service_id', sa.Integer(), nullable=False),
     sa.Column('auth_username', sa.String(length=40), nullable=False),
     sa.Column('auth_password', sa.Text(), nullable=False),
-    sa.Column('perm_users', sa.Boolean(), nullable=False),
-    sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
-    sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
-    sa.Column('perm_remailer', sa.Boolean(), nullable=False),
+    sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False),
     sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
     sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
     sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
@@ -71,9 +71,9 @@ def downgrade():
 	service = sa.Table('service', meta,
     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
     sa.Column('name', sa.String(length=255), nullable=False),
-    sa.Column('limit_access', sa.Boolean(), nullable=False),
+    sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
     sa.Column('access_group_id', sa.Integer(), nullable=True),
-    sa.Column('use_remailer', sa.Boolean(), nullable=False),
+    sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False),
     sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
     sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
     sa.UniqueConstraint('name', name=op.f('uq_service_name'))
diff --git a/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py b/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py
index 2249f759..d8d63136 100644
--- a/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py
+++ b/uffd/migrations/versions/878b25c4fae7_ldap_to_db.py
@@ -205,7 +205,7 @@ def upgrade():
 		sa.Column('displayname', sa.String(length=128), nullable=False),
 		sa.Column('mail', sa.String(length=128), nullable=False),
 		sa.Column('pwhash', sa.String(length=256), nullable=True),
-		sa.Column('is_service_user', sa.Boolean(name=op.f('ck_user_is_service_user')), nullable=False),
+		sa.Column('is_service_user', sa.Boolean(create_constraint=True, name=op.f('ck_user_is_service_user')), nullable=False),
 		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'))
@@ -337,10 +337,10 @@ def upgrade():
 		sa.Column('creator_id', sa.Integer(), nullable=True),
 		sa.Column('creator_dn', sa.String(length=128), nullable=True),
 		sa.Column('valid_until', sa.DateTime(), nullable=False),
-		sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False),
-		sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False),
-		sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False),
-		sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False),
+		sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False),
+		sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False),
+		sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False),
+		sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False),
 		sa.ForeignKeyConstraint(['creator_id'], ['user.id'], name=op.f('fk_invite_creator_id_user'), onupdate='CASCADE'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_invite')),
 		sa.UniqueConstraint('token', name=op.f('uq_invite_token'))
@@ -373,7 +373,7 @@ def upgrade():
 		batch_op.create_foreign_key(batch_op.f('fk_mfa_method_user_id_user'), 'user', ['user_id'], ['id'])
 	mfa_method = sa.Table('mfa_method', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
-		sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=True),
+		sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=True),
 		sa.Column('created', sa.DateTime(), nullable=True),
 		sa.Column('name', sa.String(length=128), nullable=True),
 		sa.Column('user_id', sa.Integer(), nullable=True),
@@ -390,7 +390,7 @@ def upgrade():
 	with op.batch_alter_table('mfa_method', copy_from=mfa_method) as batch_op:
 		batch_op.alter_column('user_id', nullable=False, existing_type=sa.Integer())
 		batch_op.alter_column('created', existing_type=sa.DateTime(), nullable=False)
-		batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=False)
+		batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=False)
 		batch_op.drop_constraint('fk_mfa_method_user_id_user', type_='foreignkey')
 		batch_op.create_foreign_key(batch_op.f('fk_mfa_method_user_id_user'), 'user', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
 		batch_op.drop_column('dn')
@@ -457,8 +457,8 @@ def upgrade():
 		sa.Column('description', sa.Text(), nullable=True),
 		sa.Column('moderator_group_id', sa.Integer(), nullable=True),
 		sa.Column('moderator_group_dn', sa.String(length=128), nullable=True),
-		sa.Column('locked', sa.Boolean(name=op.f('ck_role_locked')), nullable=False),
-		sa.Column('is_default', sa.Boolean(name=op.f('ck_role_is_default')), nullable=False),
+		sa.Column('locked', sa.Boolean(create_constraint=True, name=op.f('ck_role_locked')), nullable=False),
+		sa.Column('is_default', sa.Boolean(create_constraint=True, name=op.f('ck_role_is_default')), nullable=False),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_role')),
 		sa.UniqueConstraint('name', name=op.f('uq_role_name'))
 	)
@@ -630,7 +630,7 @@ def downgrade():
 		sa.Column('displayname', sa.String(length=128), nullable=False),
 		sa.Column('mail', sa.String(length=128), nullable=False),
 		sa.Column('pwhash', sa.String(length=256), nullable=True),
-		sa.Column('is_service_user', sa.Boolean(name=op.f('ck_user_is_service_user')), nullable=False),
+		sa.Column('is_service_user', sa.Boolean(create_constraint=True, name=op.f('ck_user_is_service_user')), nullable=False),
 		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'))
@@ -742,8 +742,8 @@ def downgrade():
 		sa.Column('description', sa.Text(), nullable=True),
 		sa.Column('moderator_group_id', sa.Integer(), nullable=True),
 		sa.Column('moderator_group_dn', sa.String(length=128), nullable=True),
-		sa.Column('locked', sa.Boolean(name=op.f('ck_role_locked')), nullable=False),
-		sa.Column('is_default', sa.Boolean(name=op.f('ck_role_is_default')), nullable=False),
+		sa.Column('locked', sa.Boolean(create_constraint=True, name=op.f('ck_role_locked')), nullable=False),
+		sa.Column('is_default', sa.Boolean(create_constraint=True, name=op.f('ck_role_is_default')), nullable=False),
 		sa.ForeignKeyConstraint(['moderator_group_id'], ['group.id'], name=op.f('fk_role_moderator_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_role')),
 		sa.UniqueConstraint('name', name=op.f('uq_role_name'))
@@ -813,7 +813,7 @@ def downgrade():
 		batch_op.add_column(sa.Column('dn', sa.String(length=128), nullable=True))
 	mfa_method = sa.Table('mfa_method', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
-		sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=False),
+		sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=False),
 		sa.Column('created', sa.DateTime(), nullable=False),
 		sa.Column('name', sa.String(length=128), nullable=True),
 		sa.Column('user_id', sa.Integer(), nullable=False),
@@ -829,7 +829,7 @@ def downgrade():
 	op.execute(mfa_method.delete().where(mfa_method.c.dn==None))
 	with op.batch_alter_table('mfa_method', copy_from=mfa_method) as batch_op:
 		batch_op.drop_constraint('fk_mfa_method_user_id_user', 'foreignkey')
-		batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='ck_mfa_method_type'), nullable=True)
+		batch_op.alter_column('type', existing_type=sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='ck_mfa_method_type'), nullable=True)
 		batch_op.alter_column('created', existing_type=sa.DateTime(), nullable=True)
 		batch_op.drop_column('user_id')
 
@@ -861,10 +861,10 @@ def downgrade():
 		sa.Column('creator_id', sa.Integer(), nullable=True),
 		sa.Column('creator_dn', sa.String(length=128), nullable=True),
 		sa.Column('valid_until', sa.DateTime(), nullable=False),
-		sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False),
-		sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False),
-		sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False),
-		sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False),
+		sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False),
+		sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False),
+		sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False),
+		sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False),
 		sa.ForeignKeyConstraint(['creator_id'], ['user.id'], name=op.f('fk_invite_creator_id_user')),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_invite')),
 		sa.UniqueConstraint('token', name=op.f('uq_invite_token'))
diff --git a/uffd/migrations/versions/a29870f95175_initial_migration.py b/uffd/migrations/versions/a29870f95175_initial_migration.py
index 828fee28..156d03c1 100644
--- a/uffd/migrations/versions/a29870f95175_initial_migration.py
+++ b/uffd/migrations/versions/a29870f95175_initial_migration.py
@@ -21,10 +21,10 @@ def upgrade():
     sa.Column('token', sa.String(length=128), nullable=False),
     sa.Column('created', sa.DateTime(), nullable=False),
     sa.Column('valid_until', sa.DateTime(), nullable=False),
-    sa.Column('single_use', sa.Boolean(), nullable=False),
-    sa.Column('allow_signup', sa.Boolean(), nullable=False),
-    sa.Column('used', sa.Boolean(), nullable=False),
-    sa.Column('disabled', sa.Boolean(), nullable=False),
+    sa.Column('single_use', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('allow_signup', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('used', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('disabled', sa.Boolean(create_constraint=True), nullable=False),
     sa.PrimaryKeyConstraint('token')
     )
     op.create_table('mailToken',
@@ -36,7 +36,7 @@ def upgrade():
     )
     op.create_table('mfa_method',
     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
-    sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='mfatype'), nullable=True),
+    sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='mfatype'), nullable=True),
     sa.Column('created', sa.DateTime(), nullable=True),
     sa.Column('name', sa.String(length=128), nullable=True),
     sa.Column('dn', sa.String(length=128), nullable=True),
diff --git a/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py b/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py
index 5ca1b89a..18f54212 100644
--- a/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py
+++ b/uffd/migrations/versions/a594d3b3e05b_added_role_locked.py
@@ -16,8 +16,20 @@ depends_on = None
 
 def upgrade():
 	with op.batch_alter_table('role', schema=None) as batch_op:
-		batch_op.add_column(sa.Column('locked', sa.Boolean(name=op.f('ck_role_locked')), nullable=False, default=False))
+		batch_op.add_column(sa.Column('locked', sa.Boolean(create_constraint=True, name=op.f('ck_role_locked')), nullable=False, default=False))
 
 def downgrade():
-	with op.batch_alter_table('role', schema=None) as batch_op:
+	meta = sa.MetaData(bind=op.get_bind())
+	table = sa.Table('role', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('name', sa.String(length=32), nullable=True),
+		sa.Column('description', sa.Text(), nullable=True),
+		sa.Column('moderator_group_dn', sa.String(length=128), nullable=True),
+		sa.Column('locked', sa.Boolean(create_constraint=False), nullable=False),
+		sa.CheckConstraint('locked IN (0, 1)', name=op.f('ck_role_locked')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_role')),
+		sa.UniqueConstraint('name', name=op.f('uq_role_name'))
+	)
+	with op.batch_alter_table('role', copy_from=table) as batch_op:
+		batch_op.drop_constraint(op.f('ck_role_locked'), 'check')
 		batch_op.drop_column('locked')
diff --git a/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py b/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py
index 3f97f8c1..d131db68 100644
--- a/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py
+++ b/uffd/migrations/versions/a60ce68b9214_fix_not_null_on_role_groups_group_id.py
@@ -22,7 +22,7 @@ def upgrade():
 	role_groups = sa.Table('role_groups', meta,
 		sa.Column('role_id', sa.Integer(), nullable=False),
 		sa.Column('group_id', sa.Integer(), nullable=True),
-		sa.Column('requires_mfa', sa.Boolean(), nullable=False),
+		sa.Column('requires_mfa', sa.Boolean(create_constraint=True), nullable=False),
 		sa.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_role_groups_group_id_group'), onupdate='CASCADE', ondelete='CASCADE'),
 		sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role_groups_role_id_role'), onupdate='CASCADE', ondelete='CASCADE'),
 		sa.PrimaryKeyConstraint('role_id', 'group_id', name=op.f('pk_role_groups'))
@@ -35,7 +35,7 @@ def downgrade():
 	role_groups = sa.Table('role_groups', meta,
 		sa.Column('role_id', sa.Integer(), nullable=False),
 		sa.Column('group_id', sa.Integer(), nullable=False),
-		sa.Column('requires_mfa', sa.Boolean(), nullable=False),
+		sa.Column('requires_mfa', sa.Boolean(create_constraint=True), nullable=False),
 		sa.ForeignKeyConstraint(['group_id'], ['group.id'], name=op.f('fk_role_groups_group_id_group'), onupdate='CASCADE', ondelete='CASCADE'),
 		sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role_groups_role_id_role'), onupdate='CASCADE', ondelete='CASCADE'),
 		sa.PrimaryKeyConstraint('role_id', 'group_id', name=op.f('pk_role_groups'))
diff --git a/uffd/migrations/versions/a8c6b6e91c28_device_login.py b/uffd/migrations/versions/a8c6b6e91c28_device_login.py
index efdbc301..e4b3a337 100644
--- a/uffd/migrations/versions/a8c6b6e91c28_device_login.py
+++ b/uffd/migrations/versions/a8c6b6e91c28_device_login.py
@@ -17,7 +17,7 @@ depends_on = None
 def upgrade():
 	op.create_table('device_login_initiation',
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
-		sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False),
+		sa.Column('type', sa.Enum('OAUTH2', create_constraint=True, name='devicelogintype'), nullable=False),
 		sa.Column('code0', sa.String(length=32), nullable=False),
 		sa.Column('code1', sa.String(length=32), nullable=False),
 		sa.Column('secret', sa.String(length=128), nullable=False),
diff --git a/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py b/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py
index 204cad29..69fc5e1d 100644
--- a/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py
+++ b/uffd/migrations/versions/aeb07202a6c8_locking_and_new_id_allocation.py
@@ -26,7 +26,7 @@ def upgrade():
 		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.Column('is_service_user', sa.Boolean(create_constraint=True), 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')),
@@ -137,7 +137,7 @@ def downgrade():
 		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.Column('is_service_user', sa.Boolean(create_constraint=True), 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')),
diff --git a/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py b/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py
index 58fb2f48..55bbf485 100644
--- a/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py
+++ b/uffd/migrations/versions/af07cea65391_unified_password_hashing_for_user_and_signup.py
@@ -37,7 +37,7 @@ def upgrade():
 		sa.Column('displayname', sa.String(length=128), nullable=False),
 		sa.Column('mail', sa.String(length=128), nullable=False),
 		sa.Column('pwhash', sa.String(length=256), nullable=True),
-		sa.Column('is_service_user', sa.Boolean(), nullable=False),
+		sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False),
 		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'))
@@ -71,7 +71,7 @@ def downgrade():
 		sa.Column('displayname', sa.String(length=128), nullable=False),
 		sa.Column('mail', sa.String(length=128), nullable=False),
 		sa.Column('pwhash', sa.Text(), nullable=True),
-		sa.Column('is_service_user', sa.Boolean(), nullable=False),
+		sa.Column('is_service_user', sa.Boolean(create_constraint=True), nullable=False),
 		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'))
diff --git a/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py b/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py
index b903fbd7..909b15d0 100644
--- a/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py
+++ b/uffd/migrations/versions/aff5f350dcdf_added_role_is_default.py
@@ -16,8 +16,22 @@ depends_on = None
 
 def upgrade():
 	with op.batch_alter_table('role', schema=None) as batch_op:
-		batch_op.add_column(sa.Column('is_default', sa.Boolean(name=op.f('ck_role_is_default')), nullable=False, default=False))
+		batch_op.add_column(sa.Column('is_default', sa.Boolean(create_constraint=True, name=op.f('ck_role_is_default')), nullable=False, default=False))
 
 def downgrade():
-	with op.batch_alter_table('role', schema=None) as batch_op:
+	meta = sa.MetaData(bind=op.get_bind())
+	table = sa.Table('role', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('name', sa.String(length=32), nullable=True),
+		sa.Column('description', sa.Text(), nullable=True),
+		sa.Column('moderator_group_dn', sa.String(length=128), nullable=True),
+		sa.Column('locked', sa.Boolean(create_constraint=False), nullable=False),
+		sa.Column('is_default', sa.Boolean(create_constraint=False), nullable=False),
+		sa.CheckConstraint('locked IN (0, 1)', name=op.f('ck_role_locked')),
+		sa.CheckConstraint('is_default IN (0, 1)', name=op.f('ck_role_is_default')),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_role')),
+		sa.UniqueConstraint('name', name=op.f('uq_role_name'))
+	)
+	with op.batch_alter_table('role', copy_from=table) as batch_op:
+		batch_op.drop_constraint(op.f('ck_role_is_default'), 'check')
 		batch_op.drop_column('is_default')
diff --git a/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py
index ca83799f..adb8e107 100644
--- a/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py
+++ b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py
@@ -36,7 +36,7 @@ def upgrade():
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
 		sa.Column('user_id', sa.Integer(), nullable=True),
 		sa.Column('address', sa.String(length=128), nullable=False),
-		sa.Column('verified', sa.Boolean(), nullable=False),
+		sa.Column('verified', sa.Boolean(create_constraint=True), nullable=False),
 		sa.Column('verification_legacy_id', sa.Integer(), nullable=True),
 		sa.Column('verification_secret', sa.Text(), nullable=True),
 		sa.Column('verification_expires', sa.DateTime(), nullable=True),
@@ -50,7 +50,7 @@ def upgrade():
 	)
 	op.execute(user_email_table.insert().from_select(
 		['user_id', 'address', 'verified'],
-		sa.select([user_table.c.id, user_table.c.mail, sa.literal(True, sa.Boolean())])
+		sa.select([user_table.c.id, user_table.c.mail, sa.literal(True, sa.Boolean(create_constraint=True))])
 	))
 	with op.batch_alter_table('user', schema=None) as batch_op:
 		batch_op.add_column(sa.Column('primary_email_id', sa.Integer(), nullable=True))
@@ -67,7 +67,7 @@ def upgrade():
 		sa.Column('primary_email_id', sa.Integer(), nullable=True),
 		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.Column('is_service_user', sa.Boolean(create_constraint=True), 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')),
@@ -110,7 +110,7 @@ def downgrade():
 		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.Column('is_service_user', sa.Boolean(create_constraint=True), 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')),
diff --git a/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py b/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py
index ca86b66a..326e8231 100644
--- a/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py
+++ b/uffd/migrations/versions/b8fbefca3675_added_api_permission_for_metrics.py
@@ -20,16 +20,16 @@ def upgrade():
 		sa.Column('service_id', sa.Integer(), nullable=False),
 		sa.Column('auth_username', sa.String(length=40), nullable=False),
 		sa.Column('auth_password', sa.Text(), nullable=False),
-		sa.Column('perm_users', sa.Boolean(), nullable=False),
-		sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
-		sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
-		sa.Column('perm_remailer', sa.Boolean(), nullable=False),
+		sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False),
 		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
 		sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
 	)
 	with op.batch_alter_table('api_client', copy_from=api_client) as batch_op:
-		batch_op.add_column(sa.Column('perm_metrics', sa.Boolean(), nullable=False, server_default=sa.false()))
+		batch_op.add_column(sa.Column('perm_metrics', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()))
 
 def downgrade():
 	meta = sa.MetaData(bind=op.get_bind())
@@ -38,11 +38,11 @@ def downgrade():
 		sa.Column('service_id', sa.Integer(), nullable=False),
 		sa.Column('auth_username', sa.String(length=40), nullable=False),
 		sa.Column('auth_password', sa.Text(), nullable=False),
-		sa.Column('perm_users', sa.Boolean(), nullable=False),
-		sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
-		sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
-		sa.Column('perm_remailer', sa.Boolean(), nullable=False),
-		sa.Column('perm_metrics', sa.Boolean(), nullable=False, server_default=sa.false()),
+		sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_remailer', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_metrics', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()),
 		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
 		sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
diff --git a/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py
index 34c1a4fd..0fd1230c 100644
--- a/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py
+++ b/uffd/migrations/versions/b9d3f7dac9db_move_api_and_oauth2_clients_to_db.py
@@ -99,7 +99,7 @@ def upgrade():
 	service_table = op.create_table('service',
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
 		sa.Column('name', sa.String(length=255), nullable=False),
-		sa.Column('limit_access', sa.Boolean(), nullable=False),
+		sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
 		sa.Column('access_group_id', sa.Integer(), nullable=True),
 		sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
@@ -118,9 +118,9 @@ def upgrade():
 		sa.Column('service_id', sa.Integer(), nullable=False),
 		sa.Column('auth_username', sa.String(length=40), nullable=False),
 		sa.Column('auth_password', sa.Text(), nullable=False),
-		sa.Column('perm_users', sa.Boolean(), nullable=False),
-		sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
-		sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
+		sa.Column('perm_users', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_checkpassword', sa.Boolean(create_constraint=True), nullable=False),
+		sa.Column('perm_mail_aliases', sa.Boolean(create_constraint=True), nullable=False),
 		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
 		sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
 		sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
@@ -164,7 +164,7 @@ def upgrade():
 		batch_op.create_foreign_key(batch_op.f('fk_device_login_initiation_oauth2_client_db_id_oauth2client'), 'oauth2client', ['oauth2_client_db_id'], ['db_id'], onupdate='CASCADE', ondelete='CASCADE')
 	device_login_initiation_table = sa.Table('device_login_initiation', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
-		sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False),
+		sa.Column('type', sa.Enum('OAUTH2', create_constraint=True, name='devicelogintype'), nullable=False),
 		sa.Column('code0', sa.String(length=32), nullable=False),
 		sa.Column('code1', sa.String(length=32), nullable=False),
 		sa.Column('secret', sa.String(length=128), nullable=False),
@@ -293,7 +293,7 @@ def downgrade():
 		batch_op.add_column(sa.Column('oauth2_client_id', sa.VARCHAR(length=40), nullable=True))
 	device_login_initiation_table = sa.Table('device_login_initiation', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
-		sa.Column('type', sa.Enum('OAUTH2', name='devicelogintype'), nullable=False),
+		sa.Column('type', sa.Enum('OAUTH2', create_constraint=True, name='devicelogintype'), nullable=False),
 		sa.Column('code0', sa.String(length=32), nullable=False),
 		sa.Column('code1', sa.String(length=32), nullable=False),
 		sa.Column('secret', sa.String(length=128), nullable=False),
diff --git a/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py b/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py
index c9786185..bd0dc72f 100644
--- a/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py
+++ b/uffd/migrations/versions/bad6fc529510_added_rolegroup_requires_mfa_and_cleanup.py
@@ -31,7 +31,7 @@ def upgrade():
 		batch_op.drop_column('id')
 		batch_op.alter_column('dn', new_column_name='group_dn', nullable=False, existing_type=sa.String(128))
 		batch_op.alter_column('role_id', nullable=False, existing_type=sa.Integer())
-		batch_op.add_column(sa.Column('requires_mfa', sa.Boolean(name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False))
+		batch_op.add_column(sa.Column('requires_mfa', sa.Boolean(create_constraint=True, name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False))
 		batch_op.create_primary_key(batch_op.f('pk_role-group'), ['role_id', 'group_dn'])
 
 def downgrade():
@@ -39,7 +39,7 @@ def downgrade():
 	table = sa.Table('role-group', meta,
 		sa.Column('role_id', sa.Integer(), nullable=False),
 		sa.Column('group_dn', sa.String(128), nullable=False),
-		sa.Column('requires_mfa', sa.Boolean(name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False),
+		sa.Column('requires_mfa', sa.Boolean(create_constraint=True, name=op.f('ck_role-group_requires_mfa')), nullable=False, default=False),
 		sa.ForeignKeyConstraint(['role_id'], ['role.id'], name=op.f('fk_role-group_role_id_role')),
 		sa.PrimaryKeyConstraint('role_id', 'group_dn', name=op.f('pk_role-group'))
 	)
diff --git a/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py b/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py
index 541e1dbf..db84513f 100644
--- a/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py
+++ b/uffd/migrations/versions/cbca20cf64d9_constraint_name_fixes.py
@@ -80,10 +80,10 @@ def upgrade():
 		sa.Column('token', sa.String(length=128), nullable=False),
 		sa.Column('created', sa.DateTime(), nullable=False),
 		sa.Column('valid_until', sa.DateTime(), nullable=False),
-		sa.Column('single_use', sa.Boolean(name=op.f('ck_invite_single_use')), nullable=False),
-		sa.Column('allow_signup', sa.Boolean(name=op.f('ck_invite_allow_signup')), nullable=False),
-		sa.Column('used', sa.Boolean(name=op.f('ck_invite_used')), nullable=False),
-		sa.Column('disabled', sa.Boolean(name=op.f('ck_invite_disabled')), nullable=False),
+		sa.Column('single_use', sa.Boolean(create_constraint=True, name=op.f('ck_invite_single_use')), nullable=False),
+		sa.Column('allow_signup', sa.Boolean(create_constraint=True, name=op.f('ck_invite_allow_signup')), nullable=False),
+		sa.Column('used', sa.Boolean(create_constraint=True, name=op.f('ck_invite_used')), nullable=False),
+		sa.Column('disabled', sa.Boolean(create_constraint=True, name=op.f('ck_invite_disabled')), nullable=False),
 		sa.PrimaryKeyConstraint('token', name=op.f('pk_invite'))
 	)
 	with op.batch_alter_table(table.name, copy_from=table, recreate='always') as batch_op:
@@ -99,7 +99,7 @@ def upgrade():
 		pass
 	table = sa.Table('mfa_method', meta,
 		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
-		sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', name='mfatype'), nullable=True),
+		sa.Column('type', sa.Enum('RECOVERY_CODE', 'TOTP', 'WEBAUTHN', create_constraint=True, name='mfatype'), nullable=True),
 		sa.Column('created', sa.DateTime(), nullable=True),
 		sa.Column('name', sa.String(length=128), nullable=True),
 		sa.Column('dn', sa.String(length=128), nullable=True),
diff --git a/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py b/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py
index e7afe43e..1652358f 100644
--- a/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py
+++ b/uffd/migrations/versions/e13b733ec856_per_service_email_preferences.py
@@ -15,7 +15,7 @@ depends_on = None
 
 def upgrade():
 	with op.batch_alter_table('service', schema=None) as batch_op:
-		batch_op.add_column(sa.Column('enable_email_preferences', sa.Boolean(), nullable=False, server_default=sa.false()))
+		batch_op.add_column(sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()))
 	with op.batch_alter_table('service_user', schema=None) as batch_op:
 		batch_op.add_column(sa.Column('service_email_id', sa.Integer(), nullable=True))
 		batch_op.create_foreign_key(batch_op.f('fk_service_user_service_email_id_user_email'), 'user_email', ['service_email_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
@@ -23,10 +23,10 @@ def upgrade():
 	service = sa.Table('service', meta,
     sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
     sa.Column('name', sa.String(length=255), nullable=False),
-    sa.Column('limit_access', sa.Boolean(), nullable=False),
+    sa.Column('limit_access', sa.Boolean(create_constraint=True), nullable=False),
     sa.Column('access_group_id', sa.Integer(), nullable=True),
-    sa.Column('use_remailer', sa.Boolean(), nullable=False),
-    sa.Column('enable_email_preferences', sa.Boolean(), nullable=False, server_default=sa.false()),
+    sa.Column('use_remailer', sa.Boolean(create_constraint=True), nullable=False),
+    sa.Column('enable_email_preferences', sa.Boolean(create_constraint=True), nullable=False, server_default=sa.false()),
     sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
     sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
     sa.UniqueConstraint('name', name=op.f('uq_service_name'))
diff --git a/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py b/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py
index 10c617ee..12ebc6ce 100644
--- a/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py
+++ b/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py
@@ -26,14 +26,14 @@ def upgrade():
 		sa.PrimaryKeyConstraint('service_id', 'user_id', name=op.f('pk_service_user'))
 	)
 	with op.batch_alter_table('service_user', copy_from=service_user) as batch_op:
-		batch_op.add_column(sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=True))
+		batch_op.add_column(sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=True))
 
 def downgrade():
 	meta = sa.MetaData(bind=op.get_bind())
 	service_user = sa.Table('service_user', meta,
 		sa.Column('service_id', sa.Integer(), nullable=False),
 		sa.Column('user_id', sa.Integer(), nullable=False),
-		sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=True),
+		sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', create_constraint=True, name='remailermode'), nullable=True),
 		sa.Column('service_email_id', sa.Integer(), nullable=True),
 		sa.ForeignKeyConstraint(['service_email_id'], ['user_email.id'], name=op.f('fk_service_user_service_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'),
 		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_service_user_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
diff --git a/uffd/models/api.py b/uffd/models/api.py
index c3dfd5e0..d57e36d3 100644
--- a/uffd/models/api.py
+++ b/uffd/models/api.py
@@ -14,11 +14,11 @@ class APIClient(db.Model):
 	auth_password = PasswordHashAttribute('_auth_password', HighEntropyPasswordHash)
 
 	# Permissions are defined by adding an attribute named "perm_NAME"
-	perm_users = Column(Boolean(), default=False, nullable=False)
-	perm_checkpassword = Column(Boolean(), default=False, nullable=False)
-	perm_mail_aliases = Column(Boolean(), default=False, nullable=False)
-	perm_remailer = Column(Boolean(), default=False, nullable=False)
-	perm_metrics = Column(Boolean(), default=False, nullable=False)
+	perm_users = Column(Boolean(create_constraint=True), default=False, nullable=False)
+	perm_checkpassword = Column(Boolean(create_constraint=True), default=False, nullable=False)
+	perm_mail_aliases = Column(Boolean(create_constraint=True), default=False, nullable=False)
+	perm_remailer = Column(Boolean(create_constraint=True), default=False, nullable=False)
+	perm_metrics = Column(Boolean(create_constraint=True), default=False, nullable=False)
 
 	@classmethod
 	def permission_exists(cls, name):
diff --git a/uffd/models/invite.py b/uffd/models/invite.py
index ef8acc36..793ade40 100644
--- a/uffd/models/invite.py
+++ b/uffd/models/invite.py
@@ -23,10 +23,10 @@ class Invite(db.Model):
 	creator_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE'), nullable=True)
 	creator = relationship('User')
 	valid_until = Column(DateTime, nullable=False)
-	single_use = Column(Boolean, default=True, nullable=False)
-	allow_signup = Column(Boolean, default=True, nullable=False)
-	used = Column(Boolean, default=False, nullable=False)
-	disabled = Column(Boolean, default=False, nullable=False)
+	single_use = Column(Boolean(create_constraint=True), default=True, nullable=False)
+	allow_signup = Column(Boolean(create_constraint=True), default=True, nullable=False)
+	used = Column(Boolean(create_constraint=True), default=False, nullable=False)
+	disabled = Column(Boolean(create_constraint=True), default=False, nullable=False)
 	roles = relationship('Role', secondary=invite_roles)
 	signups = relationship('InviteSignup', back_populates='invite', lazy=True, cascade='all, delete-orphan')
 	grants = relationship('InviteGrant', back_populates='invite', lazy=True, cascade='all, delete-orphan')
diff --git a/uffd/models/mfa.py b/uffd/models/mfa.py
index e6b34eaa..0951fb5e 100644
--- a/uffd/models/mfa.py
+++ b/uffd/models/mfa.py
@@ -9,8 +9,7 @@ import hashlib
 import base64
 import urllib.parse
 # imports for recovery codes
-import crypt
-
+import crypt # pylint: disable=deprecated-module
 from flask import request, current_app
 from sqlalchemy import Column, Integer, Enum, String, DateTime, Text, ForeignKey
 from sqlalchemy.orm import relationship, backref
@@ -29,7 +28,7 @@ class MFAType(enum.Enum):
 class MFAMethod(db.Model):
 	__tablename__ = 'mfa_method'
 	id = Column(Integer(), primary_key=True, autoincrement=True)
-	type = Column(Enum(MFAType), nullable=False)
+	type = Column(Enum(MFAType, create_constraint=True), nullable=False)
 	created = Column(DateTime(), nullable=False, default=datetime.datetime.utcnow)
 	name = Column(String(128))
 	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
diff --git a/uffd/models/role.py b/uffd/models/role.py
index 27111b8c..cc0c931d 100644
--- a/uffd/models/role.py
+++ b/uffd/models/role.py
@@ -11,7 +11,7 @@ class RoleGroup(db.Model):
 	role = relationship('Role', back_populates='groups')
 	group_id = Column(Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True)
 	group = relationship('Group')
-	requires_mfa = Column(Boolean(), default=False, nullable=False)
+	requires_mfa = Column(Boolean(create_constraint=True), default=False, nullable=False)
 
 # pylint: disable=E1101
 role_members = db.Table('role_members',
@@ -99,9 +99,9 @@ class Role(db.Model):
 	# Roles that are managed externally (e.g. by Ansible) can be locked to
 	# prevent accidental editing of name, moderator group, included roles
 	# and groups as well as deletion in the web interface.
-	locked = Column(Boolean(), default=False, nullable=False)
+	locked = Column(Boolean(create_constraint=True), default=False, nullable=False)
 
-	is_default = Column(Boolean(), default=False, nullable=False)
+	is_default = Column(Boolean(create_constraint=True), default=False, nullable=False)
 
 	@property
 	def members_effective(self):
diff --git a/uffd/models/service.py b/uffd/models/service.py
index 1817954c..7aa928d4 100644
--- a/uffd/models/service.py
+++ b/uffd/models/service.py
@@ -26,16 +26,16 @@ class Service(db.Model):
 	# parameter meant no access restrictions. Representing this state by
 	# setting access_group_id to NULL would lead to a bad/unintuitive ondelete
 	# behaviour.
-	limit_access = Column(Boolean(), default=True, nullable=False)
+	limit_access = Column(Boolean(create_constraint=True), default=True, nullable=False)
 	access_group_id = Column(Integer(), ForeignKey('group.id', onupdate='CASCADE', ondelete='SET NULL'), nullable=True)
 	access_group = relationship('Group')
 
 	oauth2_clients = relationship('OAuth2Client', back_populates='service', cascade='all, delete-orphan')
 	api_clients = relationship('APIClient', back_populates='service', cascade='all, delete-orphan')
 
-	remailer_mode = Column(Enum(RemailerMode), default=RemailerMode.DISABLED, nullable=False)
-	enable_email_preferences = Column(Boolean(), default=False, nullable=False)
-	hide_deactivated_users = Column(Boolean(), default=False, nullable=False)
+	remailer_mode = Column(Enum(RemailerMode, create_constraint=True), default=RemailerMode.DISABLED, nullable=False)
+	enable_email_preferences = Column(Boolean(create_constraint=True), default=False, nullable=False)
+	hide_deactivated_users = Column(Boolean(create_constraint=True), default=False, nullable=False)
 
 class ServiceUser(db.Model):
 	'''Service-related configuration and state for a user
@@ -63,7 +63,7 @@ class ServiceUser(db.Model):
 	def has_email_preferences(self):
 		return self.has_access and self.service.enable_email_preferences
 
-	remailer_overwrite_mode = Column(Enum(RemailerMode), default=None, nullable=True)
+	remailer_overwrite_mode = Column(Enum(RemailerMode, create_constraint=True), default=None, nullable=True)
 
 	@property
 	def effective_remailer_mode(self):
diff --git a/uffd/models/session.py b/uffd/models/session.py
index a594e231..8e5f847f 100644
--- a/uffd/models/session.py
+++ b/uffd/models/session.py
@@ -74,7 +74,7 @@ class DeviceLoginInitiation(db.Model):
 	__tablename__ = 'device_login_initiation'
 
 	id = Column(Integer(), primary_key=True, autoincrement=True)
-	type = Column(Enum(DeviceLoginType), nullable=False)
+	type = Column(Enum(DeviceLoginType, create_constraint=True), nullable=False)
 	code0 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
 	code1 = Column(String(32), unique=True, nullable=False, default=lambda: token_typeable(3))
 	secret = Column(String(128), nullable=False, default=lambda: secrets.token_hex(64))
diff --git a/uffd/models/user.py b/uffd/models/user.py
index 2a933907..48bd4cd9 100644
--- a/uffd/models/user.py
+++ b/uffd/models/user.py
@@ -167,8 +167,8 @@ class User(db.Model):
 
 	_password = Column('pwhash', Text(), nullable=True)
 	password = PasswordHashAttribute('_password', LowEntropyPasswordHash)
-	is_service_user = Column(Boolean(), default=False, nullable=False)
-	is_deactivated = Column(Boolean(), default=False, nullable=False)
+	is_service_user = Column(Boolean(create_constraint=True), default=False, nullable=False)
+	is_deactivated = Column(Boolean(create_constraint=True), default=False, nullable=False)
 	groups = relationship('Group', secondary='user_groups', back_populates='members')
 	roles = relationship('Role', secondary='role_members', back_populates='members')
 
@@ -279,7 +279,7 @@ class UserEmail(db.Model):
 		return value
 
 	# True or None/NULL (not False, see constraints below)
-	_verified = Column('verified', Boolean(), nullable=True)
+	_verified = Column('verified', Boolean(create_constraint=True), nullable=True)
 
 	@hybrid_property
 	def verified(self):
@@ -302,7 +302,7 @@ class UserEmail(db.Model):
 	# on a per-row basis.
 	# True or None/NULL if disabled (not False, see constraints below)
 	enable_strict_constraints = Column(
-		Boolean(),
+		Boolean(create_constraint=True),
 		nullable=True,
 		default=db.select([db.case([(FeatureFlag.unique_email_addresses.expr, True)], else_=None)])
 	)
diff --git a/uffd/password_hash.py b/uffd/password_hash.py
index 78d0da47..e2909064 100644
--- a/uffd/password_hash.py
+++ b/uffd/password_hash.py
@@ -1,7 +1,7 @@
 import secrets
 import hashlib
 import base64
-from crypt import crypt
+from crypt import crypt # pylint: disable=deprecated-module
 import argon2
 
 def build_value(method_name, data):
-- 
GitLab