From b7454ffa490436152088c5d391ec9f45d8b574d6 Mon Sep 17 00:00:00 2001 From: Julian Rother <julianr@fsmpi.rwth-aachen.de> Date: Thu, 15 Apr 2021 19:24:07 +0200 Subject: [PATCH] Added role moderator groups and extended invites to support delegation --- ...invite_creator_and_role_moderator_group.py | 38 +++++++++++ tests/test_role.py | 4 +- uffd/default_config.cfg | 2 + uffd/invite/models.py | 26 +++++++- uffd/invite/templates/invite/list.html | 12 +++- uffd/invite/templates/invite/new.html | 4 ++ uffd/invite/views.py | 64 ++++++++++++++++--- uffd/role/models.py | 3 + uffd/role/templates/role.html | 9 +++ uffd/role/views.py | 4 ++ uffd/session/views.py | 1 + uffd/user/models.py | 4 ++ 12 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 migrations/versions/5cab70e95bf8_invite_creator_and_role_moderator_group.py diff --git a/migrations/versions/5cab70e95bf8_invite_creator_and_role_moderator_group.py b/migrations/versions/5cab70e95bf8_invite_creator_and_role_moderator_group.py new file mode 100644 index 00000000..5e6ee341 --- /dev/null +++ b/migrations/versions/5cab70e95bf8_invite_creator_and_role_moderator_group.py @@ -0,0 +1,38 @@ +"""invite creator and role moderator group + +Revision ID: 5cab70e95bf8 +Revises: 54b2413586fd +Create Date: 2021-04-14 15:46:29.910342 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5cab70e95bf8' +down_revision = '54b2413586fd' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('invite', schema=None) as batch_op: + batch_op.add_column(sa.Column('creator_dn', sa.String(length=128), nullable=True)) + + with op.batch_alter_table('role', schema=None) as batch_op: + batch_op.add_column(sa.Column('moderator_group_dn', sa.String(length=128), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('role', schema=None) as batch_op: + batch_op.drop_column('moderator_group_dn') + + with op.batch_alter_table('invite', schema=None) as batch_op: + batch_op.drop_column('creator_dn') + + # ### end Alembic commands ### diff --git a/tests/test_role.py b/tests/test_role.py index 8dca4250..63c5c5e2 100644 --- a/tests/test_role.py +++ b/tests/test_role.py @@ -131,7 +131,7 @@ class TestRoleViews(UffdTestCase): self.assertEqual(role.description, 'Base role description') self.assertEqual([group.dn for group in role.groups], ['cn=uffd_admin,ou=groups,dc=example,dc=com']) r = self.client.post(path=url_for('role.update', roleid=role.id), - data={'name': 'base1', 'description': 'Base role description1', 'group-20001': '1', 'group-20002': '1'}, + data={'name': 'base1', 'description': 'Base role description1', 'moderator-group': '', 'group-20001': '1', 'group-20002': '1'}, follow_redirects=True) dump('role_update', r) self.assertEqual(r.status_code, 200) @@ -145,7 +145,7 @@ class TestRoleViews(UffdTestCase): def test_create(self): self.assertIsNone(Role.query.filter_by(name='base').first()) r = self.client.post(path=url_for('role.update'), - data={'name': 'base', 'description': 'Base role description', 'group-20001': '1', 'group-20002': '1'}, + data={'name': 'base', 'description': 'Base role description', 'moderator-group': '', 'group-20001': '1', 'group-20002': '1'}, follow_redirects=True) dump('role_create', r) self.assertEqual(r.status_code, 200) diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg index 1c08de08..debf69eb 100644 --- a/uffd/default_config.cfg +++ b/uffd/default_config.cfg @@ -53,6 +53,8 @@ SESSION_COOKIE_SAMESITE='Strict' ACL_ADMIN_GROUP="uffd_admin" ACL_SELFSERVICE_GROUP="uffd_access" +# Members can create invite links for signup +ACL_SIGNUP_GROUP="uffd_signup" MAIL_SERVER='' # e.g. example.com MAIL_PORT=465 diff --git a/uffd/invite/models.py b/uffd/invite/models.py index ef2ea20f..a46c387f 100644 --- a/uffd/invite/models.py +++ b/uffd/invite/models.py @@ -1,6 +1,7 @@ import secrets import datetime +from flask import current_app from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Boolean from sqlalchemy.orm import relationship from ldapalchemy.dbutils import DBRelationship @@ -20,6 +21,8 @@ class Invite(db.Model): id = Column(Integer(), primary_key=True, autoincrement=True) token = Column(String(128), unique=True, nullable=False, default=lambda: secrets.token_hex(20)) created = Column(DateTime, default=datetime.datetime.now, nullable=False) + creator_dn = Column(String(128), nullable=True) + creator = DBRelationship('creator_dn', User) valid_until = Column(DateTime, nullable=False) single_use = Column(Boolean, default=True, nullable=False) allow_signup = Column(Boolean, default=True, nullable=False) @@ -37,9 +40,30 @@ class Invite(db.Model): def voided(self): return self.single_use and self.used + @property + def permitted(self): + if self.creator_dn is None: + return True # Legacy invite link without creator + if self.creator is None: + return False # Creator does not exist (anymore) + if self.creator.is_in_group(current_app.config['ACL_ADMIN_GROUP']): + return True + if self.allow_signup and not self.creator.is_in_group(current_app.config['ACL_SIGNUP_GROUP']): + return False + for role in self.roles: + if role.moderator_group is None or role.moderator_group not in self.creator.groups: + return False + return True + @property def active(self): - return not self.disabled and not self.voided and not self.expired + return not self.disabled and not self.voided and not self.expired and self.permitted + + @property + def short_token(self): + if len(self.token) < 30: + return '<too short>' + return self.token[:10] + '…' def disable(self): self.disabled = True diff --git a/uffd/invite/templates/invite/list.html b/uffd/invite/templates/invite/list.html index c7cc9a4b..44b48371 100644 --- a/uffd/invite/templates/invite/list.html +++ b/uffd/invite/templates/invite/list.html @@ -21,7 +21,13 @@ <tbody> {% for invite in invites|sort(attribute='created', reverse=True)|sort(attribute='active', reverse=True) %} <tr> - <td><a style="width: 8em; display: inline-block;" class="text-truncate" href="{{ url_for('invite.use', token=invite.token) }}"><code>{{ invite.token }}</code></a></td> + <td> + {% if invite.creator == get_current_user() %} + <a href="{{ url_for('invite.use', token=invite.token) }}"><code>{{ invite.short_token }}</code></a> + {% else %} + <code>{{ invite.short_token }}</code> + {% endif %} + </td> <td> {% if invite.disabled %} Disabled @@ -54,9 +60,9 @@ <form action="{{ url_for('invite.disable', invite_id=invite.id) }}" method="POST"> <button type="submit" class="btn btn-link btn-sm py-0" title="Disable"><i class="fas fa-ban" style="width: 1.5em;"></i></button> </form> - {% else %} + {% elif invite.creator == get_current_user() and not invite.expired and invite.permitted %} <form action="{{ url_for('invite.reset', invite_id=invite.id) }}" method="POST"> - <button type="submit" class="btn btn-link btn-sm py-0" title="Reenable" {{ 'disabled' if invite.expired }}><i class="fas fa-redo" style="width: 1.5em;"></i></button> + <button type="submit" class="btn btn-link btn-sm py-0" title="Reenable"><i class="fas fa-redo" style="width: 1.5em;"></i></button> </form> {% endif %} </td> diff --git a/uffd/invite/templates/invite/new.html b/uffd/invite/templates/invite/new.html index ca203c25..7f2d9847 100644 --- a/uffd/invite/templates/invite/new.html +++ b/uffd/invite/templates/invite/new.html @@ -13,6 +13,7 @@ <label for="valid-until">Expires After</label> <input class="form-control" type="datetime-local" id="valid-until" name="valid-until" value="{{ (datetime.now() + timedelta(hours=36)).replace(hour=23, minute=59, second=59, microsecond=0).isoformat(timespec='minutes') }}"> </div> + {% if allow_signup %} <div class="form-group"> <label for="allow-signup">Account Registration</label> <select class="form-control" id="allow-signup" name="allow-signup"> @@ -20,6 +21,9 @@ <option value="0">No account registration allowed</option> </select> </div> + {% else %} + <input type="hidden" name="allow-signup" value="0"> + {% endif %} <div class="form-group"> <label for="valid-until">Granted Roles</label> <table class="table table-sm"> diff --git a/uffd/invite/views.py b/uffd/invite/views.py index 27ec9346..8e83dd77 100644 --- a/uffd/invite/views.py +++ b/uffd/invite/views.py @@ -2,6 +2,7 @@ import datetime import functools from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify +import sqlalchemy from uffd.csrf import csrf_protect from uffd.database import db @@ -18,8 +19,26 @@ from uffd.signup.views import signup_ratelimit bp = Blueprint('invite', __name__, template_folder='templates', url_prefix='/invite/') +def user_may_disable(user, invite): + if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): + return True + if invite.creator == user: + return True + if [role.moderator_group in user.groups for role in invite.roles]: + return True + return False + def invite_acl(): - return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP']) + if not is_valid_session(): + return False + user = get_current_user() + if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): + return True + if user.is_in_group(current_app.config['ACL_SIGNUP_GROUP']): + return True + if Role.query.filter(Role.moderator_group_dn.in_(user.group_dns)).count(): + return True + return False def invite_acl_required(func): @functools.wraps(func) @@ -31,28 +50,55 @@ def invite_acl_required(func): return func(*args, **kwargs) return decorator +def view_acl_filter(user): + if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): + return sqlalchemy.true() + creator_filter = (Invite.creator_dn == user.dn) + rolemod_filter = Invite.roles.any(Role.moderator_group_dn.in_(user.group_dns)) + return creator_filter | rolemod_filter + +def reset_acl_filter(user): + if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): + return sqlalchemy.true() + return Invite.creator_dn == user.dn + @bp.route('/') @register_navbar('Invites', icon='link', blueprint=bp, visible=invite_acl) @invite_acl_required def index(): - return render_template('invite/list.html', invites=Invite.query.all()) + invites = Invite.query.filter(view_acl_filter(get_current_user())).all() + return render_template('invite/list.html', invites=invites) @bp.route('/new') @invite_acl_required def new(): - return render_template('invite/new.html', roles=Role.query.all()) + user = get_current_user() + if user.is_in_group(current_app.config['ACL_ADMIN_GROUP']): + allow_signup = True + roles = Role.query.all() + else: + allow_signup = user.is_in_group(current_app.config['ACL_SIGNUP_GROUP']) + roles = Role.query.filter(Role.moderator_group_dn.in_(user.group_dns)).all() + return render_template('invite/new.html', roles=roles, allow_signup=allow_signup) @bp.route('/new', methods=['POST']) @invite_acl_required @csrf_protect(blueprint=bp) def new_submit(): - invite = Invite(single_use=(request.values['single-use'] == '1'), + user = get_current_user() + invite = Invite(creator=user, + single_use=(request.values['single-use'] == '1'), valid_until=datetime.datetime.fromisoformat(request.values['valid-until']), allow_signup=(request.values['allow-signup'] == '1')) for key, value in request.values.items(): if key.startswith('role-') and value == '1': - role = Role.query.get(key[5:]) - invite.roles.append(role) + invite.roles.append(Role.query.get(key[5:])) + if not invite.permitted: + flash('You are not allowed to create invite links with these permissions') + return redirect(url_for('invite.new')) + if not invite.allow_signup and not invite.roles: + flash('Invite link must either allow signup or grant at least one role') + return redirect(url_for('invite.new')) db.session.add(invite) db.session.commit() return redirect(url_for('invite.index')) @@ -61,7 +107,8 @@ def new_submit(): @invite_acl_required @csrf_protect(blueprint=bp) def disable(invite_id): - Invite.query.get_or_404(invite_id).disable() + invite = Invite.query.filter(view_acl_filter(get_current_user())).filter_by(id=invite_id).first_or_404() + invite.disable() db.session.commit() return redirect(url_for('.index')) @@ -69,7 +116,8 @@ def disable(invite_id): @invite_acl_required @csrf_protect(blueprint=bp) def reset(invite_id): - Invite.query.get_or_404(invite_id).reset() + invite = Invite.query.filter(reset_acl_filter(get_current_user())).filter_by(id=invite_id).first_or_404() + invite.reset() db.session.commit() return redirect(url_for('.index')) diff --git a/uffd/role/models.py b/uffd/role/models.py index 0b2502fa..4839b4ff 100644 --- a/uffd/role/models.py +++ b/uffd/role/models.py @@ -82,6 +82,9 @@ class Role(db.Model): backref='including_roles') including_roles = [] # overwritten by backref + moderator_group_dn = Column(String(128), nullable=True) + moderator_group = DBRelationship('moderator_group_dn', Group) + db_members = relationship("RoleUser", backref="role", cascade="all, delete-orphan") members = DBRelationship('db_members', User, RoleUser, backattr='role', backref='roles') diff --git a/uffd/role/templates/role.html b/uffd/role/templates/role.html index 6f8645c9..cbd5a115 100644 --- a/uffd/role/templates/role.html +++ b/uffd/role/templates/role.html @@ -38,6 +38,15 @@ <small class="form-text text-muted"> </small> </div> + <div class="form-group col"> + <label for="moderator-group">Moderator Group</label> + <select class="form-control" id="moderator-group" name="moderator-group"> + <option value="" class="text-muted">No Moderator Group</option> + {% for group in groups %} + <option value="{{ group.dn }}" {{ 'selected' if group == role.moderator_group }}>{{ group.name }}</option> + {% endfor %} + </select> + </div> <div class="form-group col"> <span>Members:</span> <ul class="row"> diff --git a/uffd/role/views.py b/uffd/role/views.py index eab345b5..1d77a1bf 100644 --- a/uffd/role/views.py +++ b/uffd/role/views.py @@ -74,6 +74,10 @@ def update(roleid=False): role = Role.query.filter_by(id=roleid).one() role.name = request.values['name'] role.description = request.values['description'] + if not request.values['moderator-group']: + role.moderator_group_dn = None + else: + role.moderator_group = Group.query.get(request.values['moderator-group']) 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) diff --git a/uffd/session/views.py b/uffd/session/views.py index 0d49a799..02a70d4e 100644 --- a/uffd/session/views.py +++ b/uffd/session/views.py @@ -88,6 +88,7 @@ def get_current_user(): if 'user_dn' not in session: return None return User.query.get(session['user_dn']) +bp.add_app_template_global(get_current_user) def login_valid(): user = get_current_user() diff --git a/uffd/user/models.py b/uffd/user/models.py index cb44990a..c0ce603e 100644 --- a/uffd/user/models.py +++ b/uffd/user/models.py @@ -46,6 +46,10 @@ class BaseUser(ldap.Model): groups = set() # Shuts up pylint, overwritten by back-reference roles = set() # Shuts up pylint, overwritten by back-reference + @property + def group_dns(self): + return [group.dn for group in self.groups] + def add_default_attributes(self): for name, values in current_app.config['LDAP_USER_DEFAULT_ATTRIBUTES'].items(): if self.ldap_object.getattr(name): -- GitLab