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