diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1e833fdabc7c40d402a9de69ab1a1af58f047741..c301af2dd0cbd39beda03144d01dc0bf31cd5144 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,6 +12,12 @@ before_script: - python3 -m pylint --version - python3 -m coverage --version +db_migrations_updated: + stage: test + script: + - FLASK_APP=uffd flask db upgrade + - FLASK_APP=uffd flask db migrate 2>&1 | grep -q 'No changes in schema detected' + linter: stage: test script: diff --git a/README.md b/README.md index 610ae70efefb402994bacbb73a171465e2d1a53d..f0f32c8f7cdf4967b463eebd583132253c3d9c77 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A web service to manage LDAP users, groups and permissions. - python3-ldap3 - python3-flask - python3-flask-sqlalchemy +- python3-flask-migrate - python3-qrcode - python3-fido2 (version 0.5.0, optional) - python3-flask-oauthlib @@ -18,6 +19,14 @@ You can also use virtualenv with the supplied `requirements.txt`. ## development +Before running uffd, you need to create the database with `flask db upgrade`. +Then use `flask run` to start the application: + +``` +FLASK_APP=uffd flask db upgrade +FLASK_APP=uffd FLASK_ENV=development flask run +``` + During development, you may want to enable LDAP mocking, as you otherwise need to have access to an actual LDAP server with the required schema. You can do so by setting `LDAP_SERVICE_MOCK=True` in the config. Afterwards you can login as a normal user with "testuser" and "userpassword", or as an admin with "testadmin" and "adminpassword". @@ -25,7 +34,28 @@ Please note that the mocked LDAP functionality is very limited and many uffd fea ## deployment -Use uwsgi. +Use uwsgi. Make sure to run `flask db upgrade` after every update! + +### example uwsgi config + +``` +[uwsgi] +plugin = python3 +env = PYTHONIOENCODING=UTF-8 +env = LANG=en_GB.utf8 +env = TZ=Europe/Berlin +manage-script-name = true +chdir = /var/www/uffd +module = uffd:create_app() + +uid = uffd +gid = uffd + +vacuum = true +die-on-term = true + +hook-pre-app = exec:FLASK_APP=uffd flask db upgrade +``` ## python style conventions diff --git a/create_db.py b/create_db.py deleted file mode 100755 index 5f799ab7766baff18a36782281af12f644b18bec..0000000000000000000000000000000000000000 --- a/create_db.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 -from uffd import * - -if __name__ == '__main__': - app = create_app() - init_db(app) diff --git a/migrations/README b/migrations/README new file mode 100755 index 0000000000000000000000000000000000000000..98e4f9c44effe479ed38c66ba922e7bcc672916f --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..f8ed4801f78bcb83cc6acb589508c1b24eda297a --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100755 index 0000000000000000000000000000000000000000..23663ff2f54e6c4425953537976b175246c8a9e6 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,87 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option('sqlalchemy.url', + current_app.config.get('SQLALCHEMY_DATABASE_URI')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100755 index 0000000000000000000000000000000000000000..2c0156303a8df3ffdc9de87765bf801bf6bea4a5 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/5a07d4a63b64_role_inclusion.py b/migrations/versions/5a07d4a63b64_role_inclusion.py new file mode 100644 index 0000000000000000000000000000000000000000..f10e4bba07f8c84ca45628bb3f7d3039bdd0842e --- /dev/null +++ b/migrations/versions/5a07d4a63b64_role_inclusion.py @@ -0,0 +1,30 @@ +"""Role inclusion + +Revision ID: 5a07d4a63b64 +Revises: a29870f95175 +Create Date: 2021-04-05 15:00:26.205433 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5a07d4a63b64' +down_revision = 'a29870f95175' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('role-inclusion', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('included_role_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['included_role_id'], ['role.id'], ), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), + sa.PrimaryKeyConstraint('role_id', 'included_role_id') + ) + + +def downgrade(): + op.drop_table('role-inclusion') diff --git a/migrations/versions/a29870f95175_initial_migration.py b/migrations/versions/a29870f95175_initial_migration.py new file mode 100644 index 0000000000000000000000000000000000000000..828fee28a5a4229cf83cde69ee9e5939eaba9bc8 --- /dev/null +++ b/migrations/versions/a29870f95175_initial_migration.py @@ -0,0 +1,162 @@ +"""Initial migration. + +Revision ID: a29870f95175 +Revises: +Create Date: 2021-04-04 22:46:24.930356 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a29870f95175' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('invite', + 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.PrimaryKeyConstraint('token') + ) + op.create_table('mailToken', + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('created', sa.DateTime(), nullable=True), + sa.Column('loginname', sa.String(length=32), nullable=True), + sa.Column('newmail', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('token') + ) + 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('created', sa.DateTime(), nullable=True), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('dn', sa.String(length=128), nullable=True), + sa.Column('recovery_salt', sa.String(length=64), nullable=True), + sa.Column('recovery_hash', sa.String(length=256), nullable=True), + sa.Column('totp_key', sa.String(length=64), nullable=True), + sa.Column('webauthn_cred', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('oauth2grant', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_dn', sa.String(length=128), nullable=True), + sa.Column('client_id', sa.String(length=40), nullable=True), + sa.Column('code', sa.String(length=255), nullable=False), + sa.Column('redirect_uri', sa.String(length=255), nullable=True), + sa.Column('expires', sa.DateTime(), nullable=True), + sa.Column('_scopes', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('oauth2grant', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_oauth2grant_code'), ['code'], unique=False) + + op.create_table('oauth2token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_dn', sa.String(length=128), nullable=True), + sa.Column('client_id', sa.String(length=40), nullable=True), + sa.Column('token_type', sa.String(length=40), nullable=True), + sa.Column('access_token', sa.String(length=255), nullable=True), + sa.Column('refresh_token', sa.String(length=255), nullable=True), + sa.Column('expires', sa.DateTime(), nullable=True), + sa.Column('_scopes', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('access_token'), + sa.UniqueConstraint('refresh_token') + ) + op.create_table('passwordToken', + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('created', sa.DateTime(), nullable=True), + sa.Column('loginname', sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint('token') + ) + op.create_table('ratelimit_event', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=True), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('key', sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('role', + 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.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('signup', + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('loginname', sa.Text(), nullable=True), + sa.Column('displayname', sa.Text(), nullable=True), + sa.Column('mail', sa.Text(), nullable=True), + sa.Column('pwhash', sa.Text(), nullable=True), + sa.Column('user_dn', sa.String(length=128), nullable=True), + sa.Column('type', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('token') + ) + op.create_table('invite_grant', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('invite_token', sa.String(length=128), nullable=False), + sa.Column('user_dn', sa.String(length=128), nullable=False), + sa.ForeignKeyConstraint(['invite_token'], ['invite.token'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('invite_roles', + sa.Column('invite_token', sa.String(length=128), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['invite_token'], ['invite.token'], ), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), + sa.PrimaryKeyConstraint('invite_token', 'role_id') + ) + op.create_table('invite_signup', + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('invite_token', sa.String(length=128), nullable=False), + sa.ForeignKeyConstraint(['invite_token'], ['invite.token'], ), + sa.ForeignKeyConstraint(['token'], ['signup.token'], ), + sa.PrimaryKeyConstraint('token') + ) + op.create_table('role-group', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('dn', sa.String(length=128), nullable=True), + sa.Column('role_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('dn', 'role_id') + ) + op.create_table('role-user', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('dn', sa.String(length=128), nullable=True), + sa.Column('role_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('dn', 'role_id') + ) + + +def downgrade(): + op.drop_table('role-user') + op.drop_table('role-group') + op.drop_table('invite_signup') + op.drop_table('invite_roles') + op.drop_table('invite_grant') + op.drop_table('signup') + op.drop_table('role') + op.drop_table('ratelimit_event') + op.drop_table('passwordToken') + op.drop_table('oauth2token') + with op.batch_alter_table('oauth2grant', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_oauth2grant_code')) + + op.drop_table('oauth2grant') + op.drop_table('mfa_method') + op.drop_table('mailToken') + op.drop_table('invite') diff --git a/profiling.py b/profiling.py deleted file mode 100755 index f64bd5c91e93cb21112439b8b0f0a3bfbd281823..0000000000000000000000000000000000000000 --- a/profiling.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/python3 -from werkzeug.contrib.profiler import ProfilerMiddleware -from uffd import create_app -app = create_app() -app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30]) -app.run(debug=True) diff --git a/requirements.txt b/requirements.txt index d2b691787f81594ac76b627c98c04ff72ad02e73..ff3e37c1e517174ead799d53a2beed6dea710640 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,8 @@ Flask-SQLAlchemy==2.1 qrcode==6.1 fido2==0.5.0 Flask-OAuthlib==0.9.5 +Flask-Migrate==2.1.1 +alembic==1.0.0 # The main dependencies on their own lead to version collisions and pip is # not very good at resolving them, so we pin the versions from Debian Buster @@ -28,6 +30,9 @@ six==1.12.0 SQLAlchemy==1.2.18 urllib3==1.24.1 Werkzeug==0.14.1 +python-dateutil==2.7.3 +#editor==1.0.3 +Mako==1.0.7 # Testing pytest==3.10.1 diff --git a/run.py b/run.py deleted file mode 100755 index b8ccfa5b4ba817566cc5759c8850a03452e73b5f..0000000000000000000000000000000000000000 --- a/run.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -from werkzeug.serving import make_ssl_devcert - -from uffd import * - -if __name__ == '__main__': - app = create_app() - init_db(app) - print(app.url_map) - if not os.path.exists('devcert.crt') or not os.path.exists('devcert.key'): - make_ssl_devcert('devcert') - # WebAuthn requires https and a hostname (not just an IP address). If you - # don't want to test U2F/FIDO2 device registration/authorization, you can - # safely remove `host` and `ssl_context`. - app.run(threaded=True, debug=True, host='localhost', ssl_context=('devcert.crt', 'devcert.key')) diff --git a/tests/test_role.py b/tests/test_role.py index c6a42bdc09a0f7e0e87643c84dd75cb7b7699fee..8dca42503767c1e3303bc3732d02a4486ef32c8e 100644 --- a/tests/test_role.py +++ b/tests/test_role.py @@ -6,12 +6,89 @@ from flask import url_for, session # These imports are required, because otherwise we get circular imports?! from uffd import ldap, user -from uffd.user.models import Group +from uffd.user.models import User, Group from uffd.role.models import Role from uffd import create_app, db from utils import dump, UffdTestCase +class TestUserRoleAttributes(UffdTestCase): + def test_roles_recursive(self): + user1 = User.query.get('uid=testuser,ou=users,dc=example,dc=com') + user1.update_groups() + baserole = Role(name='base') + role1 = Role(name='role1', members=[user1], included_roles=[baserole]) + role2 = Role(name='role2', included_roles=[baserole]) + db.session.add_all([baserole, role1, role2]) + self.assertSetEqual(user1.roles_recursive, {baserole, role1}) + baserole.included_roles.append(role2) + self.assertSetEqual(user1.roles_recursive, {baserole, role1, role2}) + + def test_update_groups(self): + user1 = User.query.get('uid=testuser,ou=users,dc=example,dc=com') + user1.update_groups() + self.assertSetEqual(set(user1.groups), set()) + group1 = Group.query.get('cn=users,ou=groups,dc=example,dc=com') + group2 = Group.query.get('cn=uffd_access,ou=groups,dc=example,dc=com') + baserole = Role(name='base', groups=[group1]) + role1 = Role(name='role1', groups=[group2], members=[user1]) + db.session.add_all([baserole, role1]) + user1.update_groups() + self.assertSetEqual(set(user1.groups), {group2}) + role1.included_roles.append(baserole) + user1.update_groups() + self.assertSetEqual(set(user1.groups), {group1, group2}) + +class TestRoleModel(UffdTestCase): + def test_indirect_members(self): + user1 = User.query.get('uid=testuser,ou=users,dc=example,dc=com') + user1.update_groups() + user2 = User.query.get('uid=testadmin,ou=users,dc=example,dc=com') + user2.update_groups() + baserole = Role(name='base', members=[user1]) + role1 = Role(name='role1', included_roles=[baserole], members=[user2]) + self.assertSetEqual(baserole.indirect_members, {user2}) + self.assertSetEqual(role1.indirect_members, set()) + + def test_included_roles_recursive(self): + baserole = Role(name='base') + role1 = Role(name='role1', included_roles=[baserole]) + role2 = Role(name='role2', included_roles=[baserole]) + role3 = Role(name='role3', included_roles=[role1, role2]) + self.assertSetEqual(role1.included_roles_recursive, {baserole}) + self.assertSetEqual(role2.included_roles_recursive, {baserole}) + self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2}) + baserole.included_roles.append(role1) + self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2}) + + def test_included_groups(self): + group1 = Group.query.get('cn=users,ou=groups,dc=example,dc=com') + group2 = Group.query.get('cn=uffd_access,ou=groups,dc=example,dc=com') + baserole = Role(name='base', groups=[group1]) + role1 = Role(name='role1', groups=[group2], included_roles=[baserole]) + self.assertSetEqual(baserole.included_groups, set()) + self.assertSetEqual(role1.included_groups, {group1}) + + def test_update_member_groups(self): + user1 = User.query.get('uid=testuser,ou=users,dc=example,dc=com') + user1.update_groups() + user2 = User.query.get('uid=testadmin,ou=users,dc=example,dc=com') + user2.update_groups() + group1 = Group.query.get('cn=users,ou=groups,dc=example,dc=com') + group2 = Group.query.get('cn=uffd_access,ou=groups,dc=example,dc=com') + group3 = Group.query.get('cn=uffd_admin,ou=groups,dc=example,dc=com') + baserole = Role(name='base', members=[user1], groups=[group1]) + role1 = Role(name='role1', members=[user2], groups=[group2], included_roles=[baserole]) + db.session.add_all([baserole, role1]) + baserole.update_member_groups() + role1.update_member_groups() + self.assertSetEqual(set(user1.groups), {group1}) + self.assertSetEqual(set(user2.groups), {group1, group2}) + baserole.groups.add(group3) + baserole.update_member_groups() + self.assertSetEqual(set(user1.groups), {group1, group3}) + self.assertSetEqual(set(user2.groups), {group1, group2, group3}) + class TestRoleViews(UffdTestCase): def setUp(self): super().setUp() diff --git a/uffd/__init__.py b/uffd/__init__.py index 65b825befe2d3aee374afe05943862cb6ce4ae32..53d6965f9fed9f0b063ad5bc420425219f4f4121 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -4,11 +4,15 @@ import sys from flask import Flask, redirect, url_for, request from werkzeug.routing import IntegerConverter +from werkzeug.serving import make_ssl_devcert +from werkzeug.contrib.profiler import ProfilerMiddleware +from flask_migrate import Migrate sys.path.append('deps/ldapalchemy') # pylint: disable=wrong-import-position from uffd.database import db, SQLAlchemyJSON +from uffd.ldap import ldap from uffd.template_helper import register_template_helper from uffd.navbar import setup_navbar # pylint: enable=wrong-import-position @@ -42,7 +46,7 @@ def create_app(test_config=None): # pylint: disable=too-many-locals pass db.init_app(app) - + Migrate(app, db, render_as_batch=True) # pylint: disable=C0415 from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, services # pylint: enable=C0415 @@ -60,6 +64,11 @@ def create_app(test_config=None): # pylint: disable=too-many-locals app.config['ENABLE_PASSWORDRESET'] = False + @app.shell_context_processor + def push_request_context(): #pylint: disable=unused-variable + app.test_request_context().push() # LDAP ORM requires request context + return {'db': db, 'ldap': ldap} + @app.route("/") def index(): #pylint: disable=unused-variable return redirect(url_for('selfservice.index')) @@ -69,8 +78,22 @@ def create_app(test_config=None): # pylint: disable=too-many-locals if hasattr(request, "ldap_connection"): request.ldap_connection.unbind() - return app + @app.cli.command("gendevcert", help='Generates a self-signed TLS certificate for development') + def gendevcert(): #pylint: disable=unused-variable + if os.path.exists('devcert.crt') or os.path.exists('devcert.key'): + print('Refusing to overwrite existing "devcert.crt"/"devcert.key" file!') + return + make_ssl_devcert('devcert') + print('Certificate written to "devcert.crt", private key to "devcert.key".') + print('Run `flask run --cert devcert.crt --key devcert.key` to use it.') + + @app.cli.command("profile", help='Runs app with profiler') + def profile(): #pylint: disable=unused-variable + # app.run() is silently ignored if executed from commands. We really want + # to do this, so we overwrite the check by overwriting the environment + # variable. + os.environ['FLASK_RUN_FROM_CLI'] = 'false' + app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30]) + app.run(debug=True) -def init_db(app): - with app.app_context(): - db.create_all() + return app diff --git a/uffd/mail/templates/mail_list.html b/uffd/mail/templates/mail_list.html index 844689516dc0d78d21a2126b4d886319db1ad6ff..f994b140ea7b842eba6b6fb19a86d89768a35fa4 100644 --- a/uffd/mail/templates/mail_list.html +++ b/uffd/mail/templates/mail_list.html @@ -3,19 +3,17 @@ {% block body %} <div class="row"> <div class="col"> + <p class="text-right"> + <a class="btn btn-primary" href="{{ url_for("mail.show") }}"> + <i class="fa fa-plus" aria-hidden="true"></i> New + </a> + </p> <table class="table table-striped table-sm"> <thead> <tr> <th scope="col">name</th> <th scope="col">receiving address</th> <th scope="col">destinations</th> - <th scope="col"> - <p class="text-right"> - <a class="btn btn-primary" href="{{ url_for("mail.show") }}"> - <i class="fa fa-plus" aria-hidden="true"></i> New - </a> - </p> - </th> </tr> </thead> <tbody> @@ -27,26 +25,19 @@ </a> </th> <td> - <ul> + <ul class="m-0"> {% for i in mail.receivers %} <li>{{ i }}</li> {% endfor %} </ul> </td> <td> - <ul> + <ul class="m-0"> {% for i in mail.destinations %} <li>{{ i }}</li> {% endfor %} </ul> </td> - <td> - <p class="text-right"> - <a href="{{ url_for("mail.show", uid=mail.uid) }}" class="btn btn-primary"> - <i class="fa fa-edit" aria-hidden="true"></i> Edit - </a> - </p> - </td> </tr> {% endfor %} </tbody> diff --git a/uffd/role/models.py b/uffd/role/models.py index 5371d65e4bb594edbacf65ffc44281bacccdd3a9..0b2502fa263dd69721e38cf6b83c9fad4d1ead72 100644 --- a/uffd/role/models.py +++ b/uffd/role/models.py @@ -7,26 +7,67 @@ from ldapalchemy.dbutils import DBRelationship from uffd.database import db from uffd.user.models import User, Group -class LdapMapping: +class RoleGroup(db.Model): + __tablename__ = 'role-group' + __table_args__ = ( + db.UniqueConstraint('dn', 'role_id'), + ) + id = Column(Integer(), primary_key=True, autoincrement=True) dn = Column(String(128)) + + @declared_attr + def role_id(self): + return Column(ForeignKey('role.id')) + +class RoleUser(db.Model): + __tablename__ = 'role-user' __table_args__ = ( db.UniqueConstraint('dn', 'role_id'), ) + + id = Column(Integer(), primary_key=True, autoincrement=True) + dn = Column(String(128)) + @declared_attr def role_id(self): return Column(ForeignKey('role.id')) -class RoleGroup(LdapMapping, db.Model): - __tablename__ = 'role-group' +# pylint: disable=E1101 +role_inclusion = db.Table('role-inclusion', + Column('role_id', Integer, ForeignKey('role.id'), primary_key=True), + Column('included_role_id', Integer, ForeignKey('role.id'), primary_key=True) +) -class RoleUser(LdapMapping, db.Model): - __tablename__ = 'role-user' +def flatten_recursive(objs, attr): + '''Returns a set of objects and all objects included in object.`attr` recursivly while avoiding loops''' + objs = set(objs) + new_objs = set(objs) + while new_objs: + for obj in getattr(new_objs.pop(), attr): + if obj not in objs: + objs.add(obj) + new_objs.add(obj) + return objs + +def get_roles_recursive(user): + return flatten_recursive(user.roles, 'included_roles') + +User.roles_recursive = property(get_roles_recursive) def update_user_groups(user): - user.groups.clear() - for role in user.roles: - user.groups.update(role.groups) + current_groups = set(user.groups) + groups = set() + for role in user.roles_recursive: + groups.update(role.groups) + if groups == current_groups: + return set(), set() + groups_added = groups - current_groups + groups_removed = current_groups - groups + for group in groups_removed: + user.groups.discard(group) + user.groups.update(groups_added) + return groups_added, groups_removed User.update_groups = update_user_groups @@ -35,6 +76,11 @@ class Role(db.Model): id = Column(Integer(), primary_key=True, autoincrement=True) name = Column(String(32), unique=True) description = Column(Text(), default='') + included_roles = relationship('Role', secondary=role_inclusion, + primaryjoin=id == role_inclusion.c.role_id, + secondaryjoin=id == role_inclusion.c.included_role_id, + backref='including_roles') + including_roles = [] # overwritten by backref db_members = relationship("RoleUser", backref="role", cascade="all, delete-orphan") members = DBRelationship('db_members', User, RoleUser, backattr='role', backref='roles') @@ -42,6 +88,24 @@ class Role(db.Model): db_groups = relationship("RoleGroup", backref="role", cascade="all, delete-orphan") groups = DBRelationship('db_groups', Group, RoleGroup, backattr='role', backref='roles') + @property + def indirect_members(self): + users = set() + for role in flatten_recursive(self.including_roles, 'including_roles'): + users.update(role.members) + return users + + @property + def included_roles_recursive(self): + return flatten_recursive(self.included_roles, 'included_roles') + + @property + def included_groups(self): + groups = set() + for role in self.included_roles_recursive: + groups.update(role.groups) + return groups + def update_member_groups(self): - for user in self.members: + for user in set(self.members).union(self.indirect_members): user.update_groups() diff --git a/uffd/role/templates/role.html b/uffd/role/templates/role.html index 3b2fa8e1ac9a3ae481070aa926c30edf40c13be0..6f8645c9f83bfba94fc097eb5934ee25290d4559 100644 --- a/uffd/role/templates/role.html +++ b/uffd/role/templates/role.html @@ -3,69 +3,125 @@ {% block body %} <form action="{{ url_for("role.update", roleid=role.id) }}" method="POST"> <div class="align-self-center"> - <div class="form-group col"> - <label for="role-name">Role Name</label> - <input type="text" class="form-control" id="role-name" name="name" value="{{ role.name }}"> - <small class="form-text text-muted"> - </small> - </div> - <div class="form-group col"> - <label for="role-description">Description</label> - <textarea class="form-control" id="role-description" name="description" rows="5">{{ role.description }}</textarea> - <small class="form-text text-muted"> - </small> - </div> - <div class="form-group col"> - <span>Included groups</span> - <table class="table table-striped table-sm"> - <thead> - <tr> - <th scope="col"></th> - <th scope="col">name</th> - <th scope="col">description</th> - </tr> - </thead> - <tbody> - {% for group in groups|sort(attribute="name") %} - <tr id="group-{{ group.gid }}"> - <td> - <div class="form-check"> - <input class="form-check-input" type="checkbox" id="group-{{ group.gid }}-checkbox" name="group-{{ group.gid }}" value="1" aria-label="enabled" {% if group in role.groups %}checked{% endif %}> - </div> - </td> - <td> - <a href="{{ url_for("group.show", gid=group.gid) }}"> - {{ group.name }} - </a> - </td> - <td> - {{ group.description }} - </td> - </tr> - {% endfor %} - </tbody> - </table> - </div> - <div class="form-group col"> - <p> - Members - </p> - <ul> - {% for dbmember in role.db_members %} - <li>{{ dbmember.dn }}</li> - {% endfor %} - </ul> - </div> - - <div class="form-group col"> + <div class="float-sm-right pb-2"> <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> Save</button> <a href="{{ url_for("role.index") }}" class="btn btn-secondary">Cancel</a> {% if role.id %} - <a href="{{ url_for("role.delete", roleid=role.id) }}" class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a> + <a href="{{ url_for("role.delete", roleid=role.id) }}" onClick="return confirm('Are you sure?');" class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a> {% else %} <a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> Delete</a> {% endif %} </div> + <ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist"> + <li class="nav-item"> + <a class="nav-link active" id="settings-tab" data-toggle="tab" href="#settings" role="tab" aria-controls="settings" aria-selected="true">Settings</a> + </li> + <li class="nav-item"> + <a class="nav-link" id="roles-tab" data-toggle="tab" href="#roles" role="tab" aria-controls="roles" aria-selected="false">Included roles <span class="badge badge-pill badge-secondary">{{ role.included_roles|length }}</span></a> + </li> + <li class="nav-item"> + <a class="nav-link" id="groups-tab" data-toggle="tab" href="#groups" role="tab" aria-controls="groups" aria-selected="false">Included groups <span class="badge badge-pill badge-secondary">{{ role.groups|length }}</span></a> + </li> + </ul> + + <div class="tab-content border mb-2 pt-2" id="tabcontent"> + <div class="tab-pane fade show active" id="settings" role="tabpanel" aria-labelledby="settings-tab"> + <div class="form-group col"> + <label for="role-name">Role Name</label> + <input type="text" class="form-control" id="role-name" name="name" value="{{ role.name }}"> + <small class="form-text text-muted"> + </small> + </div> + <div class="form-group col"> + <label for="role-description">Description</label> + <textarea class="form-control" id="role-description" name="description" rows="5">{{ role.description }}</textarea> + <small class="form-text text-muted"> + </small> + </div> + <div class="form-group col"> + <span>Members:</span> + <ul class="row"> + {% for member in role.members|sort(attribute='loginname') %} + <li class="col-12 col-xs-6 col-sm-4 col-md-3 col-lg-2"><a href="{{ url_for("user.show", uid=member.uid) }}">{{ member.loginname }}</a></li> + {% endfor %} + </ul> + </div> + </div> + <div class="tab-pane fade" id="roles" role="tabpanel" aria-labelledby="roles-tab"> + <div class="form-group col"> + <span>Roles to include groups from recursively</span> + <table class="table table-striped table-sm"> + <thead> + <tr> + <th scope="col"></th> + <th scope="col">name</th> + <th scope="col">description</th> + <th scope="col">currently includes groups</th> + </tr> + </thead> + <tbody> + {% for r in roles|sort(attribute="name")|sort(attribute='name') %} + <tr id="include-role-{{ role.id }}"> + <td> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="include-role-{{ r.id }}-checkbox" name="include-role-{{ r.id }}" value="1" aria-label="enabled" + {% if r == role %}disabled{% endif %} + {% if r in role.included_roles %}checked{% endif %}> + </div> + </td> + <td> + <a href="{{ url_for("role.show", roleid=r.id) }}"> + {{ r.name }} + </a> + </td> + <td> + {{ r.description }} + </td> + <td> + {% for group in r.included_groups.union(r.groups)|sort(attribute='name') %} + <a href="{{ url_for("group.show", gid=group.gid) }}">{{ group.name }}</a>{{ ', ' if not loop.last }} + {% endfor %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + <div class="tab-pane fade" id="groups" role="tabpanel" aria-labelledby="groups-tab"> + <div class="form-group col"> + <span>Included groups</span> + <table class="table table-striped table-sm"> + <thead> + <tr> + <th scope="col"></th> + <th scope="col">name</th> + <th scope="col">description</th> + </tr> + </thead> + <tbody> + {% for group in groups|sort(attribute="name") %} + <tr id="group-{{ group.gid }}"> + <td> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="group-{{ group.gid }}-checkbox" name="group-{{ group.gid }}" value="1" aria-label="enabled" {% if group in role.groups %}checked{% endif %}> + </div> + </td> + <td> + <a href="{{ url_for("group.show", gid=group.gid) }}"> + {{ group.name }} + </a> + </td> + <td> + {{ group.description }} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> </div> </form> {% endblock %} diff --git a/uffd/role/templates/role_list.html b/uffd/role/templates/role_list.html index 1e2f31fa6e17394a3397c533b747f37cd7a0277e..54b5a7d6458202e66af84fc73d8e08caee177733 100644 --- a/uffd/role/templates/role_list.html +++ b/uffd/role/templates/role_list.html @@ -3,19 +3,17 @@ {% block body %} <div class="row"> <div class="col"> - <table class="table table-striped"> + <p class="text-right"> + <a class="btn btn-primary" href="{{ url_for("role.show") }}"> + <i class="fa fa-plus" aria-hidden="true"></i> New + </a> + </p> + <table class="table table-striped table-sm"> <thead> <tr> - <th scope="col">id</th> + <th scope="col">roleid</th> <th scope="col">name</th> <th scope="col">description</th> - <th scope="col"> - <p class="text-right"> - <a class="btn btn-primary" href="{{ url_for("role.show") }}"> - <i class="fa fa-plus" aria-hidden="true"></i> New - </a> - </p> - </th> </tr> </thead> <tbody> @@ -25,18 +23,13 @@ {{ role.id }} </td> <th scope="row"> - {{ role.name }} + <a href="{{ url_for("role.show", roleid=role.id) }}"> + {{ role.name or '<empty name>' }} + </a> </th> <td> {{ role.description }} </td> - <td> - <p class="text-right"> - <a href="{{ url_for("role.show", roleid=role.id) }}" class="btn btn-primary"> - <i class="fa fa-edit" aria-hidden="true"></i> Edit - </a> - </p> - </td> </tr> {% endfor %} </tbody> diff --git a/uffd/role/views.py b/uffd/role/views.py index e915ce5a53a5d0455aab08353ce08266480abac7..eab345b59a8a77a63dcd1b92cd4c0dccbc370ff6 100644 --- a/uffd/role/views.py +++ b/uffd/role/views.py @@ -1,14 +1,41 @@ +import sys + from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app +import click from uffd.navbar import register_navbar from uffd.csrf import csrf_protect from uffd.role.models import Role -from uffd.user.models import Group +from uffd.user.models import User, Group from uffd.session import get_current_user, login_required, is_valid_session from uffd.database import db from uffd.ldap import ldap bp = Blueprint("role", __name__, template_folder='templates', url_prefix='/role/') + +@bp.record +def add_cli_commands(state): + @state.app.cli.command('roles-update-all', help='Update group memberships for all users based on their roles') + @click.option('--check-only', is_flag=True) + def roles_update_all(check_only): #pylint: disable=unused-variable + consistent = True + with current_app.test_request_context(): + for user in User.query.all(): + groups_added, groups_removed = user.update_groups() + if groups_added: + consistent = False + print('Adding groups [%s] to user %s'%(', '.join([group.name for group in groups_added]), user.dn)) + if groups_removed: + consistent = False + print('Removing groups [%s] from user %s'%(', '.join([group.name for group in groups_removed]), user.dn)) + if not check_only: + ldap.session.commit() + if check_only and not consistent: + print('No changes were made because --check-only is set') + print() + print('Error: LDAP groups are not consistent with roles in database') + sys.exit(1) + @bp.before_request @login_required() def role_acl(): #pylint: disable=inconsistent-return-statements @@ -27,11 +54,13 @@ def index(): @bp.route("/<int:roleid>") @bp.route("/new") def show(roleid=False): + # prefetch all users so the ldap orm can cache them and doesn't run one ldap query per user + User.query.all() if not roleid: role = Role() else: role = Role.query.filter_by(id=roleid).one() - return render_template('role.html', role=role, groups=Group.query.all()) + return render_template('role.html', role=role, groups=Group.query.all(), roles=Role.query.all()) @bp.route("/<int:roleid>/update", methods=['POST']) @bp.route("/new", methods=['POST']) @@ -45,6 +74,11 @@ def update(roleid=False): role = Role.query.filter_by(id=roleid).one() role.name = request.values['name'] role.description = request.values['description'] + for included_role in Role.query.all(): + if included_role != role and request.values.get('include-role-{}'.format(included_role.id)): + role.included_roles.append(included_role) + elif included_role in role.included_roles: + role.included_roles.remove(included_role) for group in Group.query.all(): if request.values.get('group-{}'.format(group.gid), False): role.groups.add(group) @@ -53,13 +87,13 @@ def update(roleid=False): role.update_member_groups() db.session.commit() ldap.session.commit() - return redirect(url_for('role.index')) + return redirect(url_for('role.show', roleid=roleid)) @bp.route("/<int:roleid>/del") @csrf_protect(blueprint=bp) def delete(roleid): role = Role.query.filter_by(id=roleid).one() - oldmembers = list(role.members) + oldmembers = set(role.members).union(role.indirect_members) role.members.clear() db.session.delete(role) for user in oldmembers: diff --git a/uffd/user/templates/group.html b/uffd/user/templates/group.html index 9f5333a683eee7f505bb4e745987e59844a3410c..e98726d817abbb505b21c43975d5b014edd87a5d 100644 --- a/uffd/user/templates/group.html +++ b/uffd/user/templates/group.html @@ -13,9 +13,9 @@ </div> <div class="col"> <span>Members:</span> - <ul class="list-group"> - {% for member in group.members %} - <li class="list-group-item"><a href="{{ url_for("user.show", uid=member.uid) }}">{{ member.loginname }}</a></li> + <ul class="row"> + {% for member in group.members|sort(attribute='loginname') %} + <li class="col-12 col-xs-6 col-sm-4 col-md-3 col-lg-2"><a href="{{ url_for("user.show", uid=member.uid) }}">{{ member.loginname }}</a></li> {% endfor %} </ul> </div> diff --git a/uffd/user/templates/group_list.html b/uffd/user/templates/group_list.html index 906c9f1f2fa0082cd479146cd3cc3b983f030f21..f8a35b10b9181bb2403c9d679ff7c774f55eb497 100644 --- a/uffd/user/templates/group_list.html +++ b/uffd/user/templates/group_list.html @@ -3,7 +3,7 @@ {% block body %} <div class="row"> <div class="col"> - <table class="table table-striped"> + <table class="table table-striped table-sm"> <thead> <tr> <th scope="col">gid</th> diff --git a/uffd/user/templates/user_list.html b/uffd/user/templates/user_list.html index 34102af796966bbbcd35c90c2ed2557b4bda423d..9e420323bf8cbe7824d4a76a93a9a9dfa49e30bc 100644 --- a/uffd/user/templates/user_list.html +++ b/uffd/user/templates/user_list.html @@ -3,22 +3,21 @@ {% block body %} <div class="row"> <div class="col"> + <p class="text-right"> + <a class="btn btn-primary" href="{{ url_for("user.show") }}"> + <i class="fa fa-plus" aria-hidden="true"></i> New + </a> + <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#csvimport"> + <i class="fa fa-file-csv" aria-hidden="true"></i> CSV import + </button> + </p> <table class="table table-striped table-sm"> <thead> <tr> <th scope="col">uid</th> <th scope="col">login name</th> <th scope="col">display name</th> - <th scope="col"> - <p class="text-right"> - <a class="btn btn-primary" href="{{ url_for("user.show") }}"> - <i class="fa fa-plus" aria-hidden="true"></i> New - </a> - <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#csvimport"> - <i class="fa fa-file-csv" aria-hidden="true"></i> CSV import - </button> - </p> - </th> + <th scope="col">roles</th> </tr> </thead> <tbody> @@ -36,11 +35,9 @@ {{ user.displayname }} </td> <td> - <p class="text-right"> - <a href="{{ url_for("user.show", uid=user.uid) }}" class="btn btn-primary"> - <i class="fa fa-edit" aria-hidden="true"></i> Edit - </a> - </p> + {% for role in user.roles|sort(attribute="name") if not role.name in config["ROLES_BASEROLES"] %} + <a href="{{ url_for("role.show", roleid=role.id) }}">{{ role.name }}</a>{% if not loop.last %}, {% endif %} + {% endfor %} </td> </tr> {% endfor %}