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 0000000000000000000000000000000000000000..5e6ee34171b88e7dce55e7cce36db3e83f0848c4 --- /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 8dca42503767c1e3303bc3732d02a4486ef32c8e..63c5c5e2204c5940352df1654cbf7cddcef725f2 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 1c08de086756bc3ea8309df7479ecd809593e58c..debf69ebf818a04d38bf32f29ac2ff57350b842b 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 ef2ea20f45c8e990a08e31c94984378fa6c2b959..a46c387fa9fa71c36608a80443873f83a873fb3b 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 c7cc9a4bf3437c68e0696c762eb0fde3dfb4a1e3..44b48371e1ca94ea904177b2305def1242adda6c 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 ca203c25a87b00e36ea49fad3113dc8491f8f06c..7f2d984781bd9526f5dd11760b7a81ad8d04b80d 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 27ec934625a6d71bf56456162c6c8f9941bf0309..8e83dd773e8519e0bd0f1ea5d7efa892e70d8715 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 0b2502fa263dd69721e38cf6b83c9fad4d1ead72..4839b4ffe924dbe94d21bc42e3ece710f352c2de 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 6f8645c9f83bfba94fc097eb5934ee25290d4559..cbd5a115c4a51b3bcfceada3efe2886eb0f76558 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 eab345b59a8a77a63dcd1b92cd4c0dccbc370ff6..1d77a1bf1cd659078fd98e4638161846a7e5c6be 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 0d49a799a65c0ae4fe1aa1a9a54a174bdb945b6c..02a70d4e5edcbb42dfd84d24b2d99ee31b586538 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 cb44990a42b63b20030b7e1e4e691b8666de039e..c0ce603e3d39fdf4dd49b4926ff9f957fefdcd93 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):