From 8e73775e137bcd60bf83600f5ab7c2fee5bc9aec Mon Sep 17 00:00:00 2001
From: nd <git@notandy.de>
Date: Sun, 19 Jul 2020 19:44:26 +0200
Subject: [PATCH] add users to roles

---
 .pylintrc                          |  4 +--
 uffd/role/__init__.py              |  1 -
 uffd/role/models.py                | 33 ++++++++++++++++---
 uffd/role/views.py                 | 18 ++++++-----
 uffd/user/__init__.py              |  1 -
 uffd/user/models.py                | 34 +++++++++++++++++---
 uffd/user/templates/user.html      | 51 +++++++++++++++++++++++-------
 uffd/user/templates/user_list.html |  2 +-
 uffd/user/views_user.py            | 29 ++++++++++++++---
 9 files changed, 135 insertions(+), 38 deletions(-)

diff --git a/.pylintrc b/.pylintrc
index 038648ff..275ec9f1 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -560,13 +560,13 @@ valid-metaclass-classmethod-first-arg=cls
 max-args=7
 
 # Maximum number of attributes for a class (see R0902).
-max-attributes=7
+max-attributes=12
 
 # Maximum number of boolean expressions in an if statement (see R0916).
 max-bool-expr=5
 
 # Maximum number of branch for function / method body.
-max-branches=12
+max-branches=15
 
 # Maximum number of locals for function / method body.
 max-locals=15
diff --git a/uffd/role/__init__.py b/uffd/role/__init__.py
index cd6180ed..67157866 100644
--- a/uffd/role/__init__.py
+++ b/uffd/role/__init__.py
@@ -1,4 +1,3 @@
 from .views import bp as bp_ui
-from .models import Role, RoleGroup, RoleUser
 
 bp = [bp_ui]
diff --git a/uffd/role/models.py b/uffd/role/models.py
index 596f2e0d..d8b379cd 100644
--- a/uffd/role/models.py
+++ b/uffd/role/models.py
@@ -5,7 +5,7 @@ from sqlalchemy.orm import relationship
 from sqlalchemy.ext.declarative import declared_attr
 
 from uffd.database import db
-from uffd.user import User, Group
+from uffd.user.models import User, Group
 
 class Role(db.Model):
 	__tablename__ = 'role'
@@ -19,10 +19,31 @@ class Role(db.Model):
 		self.name = name
 		self.description = description
 
-	def group_dns(self):
-		return map(attrgetter('dn'), self.groups)
+	@classmethod
+	def get_for_user(cls, user):
+		return Role.query.join(Role.members, aliased=True).filter_by(dn=user.dn)
+
 	def member_dns(self):
-		return map(attrgetter('dn'), self.members)
+		return list(map(attrgetter('dn'), self.members))
+	def add_member(self, member):
+		newmapping = RoleUser(member.dn, self)
+		self.members.append(newmapping)
+	def del_member(self, member):
+		for i in self.members:
+			if i.dn == member.dn:
+				self.members.remove(i)
+				break
+
+	def group_dns(self):
+		return list(map(attrgetter('dn'), self.groups))
+	def add_group(self, group):
+		newmapping = RoleGroup(group.dn, self)
+		self.groups.append(newmapping)
+	def del_group(self, group):
+		for i in self.groups:
+			if i.dn == group.dn:
+				self.groups.remove(i)
+				break
 
 class LdapMapping():
 	id = Column(Integer(), primary_key=True, autoincrement=True)
@@ -35,6 +56,10 @@ class LdapMapping():
 		return Column(ForeignKey('role.id'))
 	ldapclass = None
 
+	def __init__(self, dn='', role=''):
+		self.dn = dn
+		self.role = role
+
 	def get_ldap(self):
 		return self.ldapclass.from_ldap_dn(self.dn)
 
diff --git a/uffd/role/views.py b/uffd/role/views.py
index 737d1c6b..497333fa 100644
--- a/uffd/role/views.py
+++ b/uffd/role/views.py
@@ -2,8 +2,8 @@ from flask import Blueprint, render_template, request, url_for, redirect, flash,
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
-from uffd.role.models import Role, RoleGroup
-from uffd.user import Group
+from uffd.role.models import Role
+from uffd.user.models import Group
 from uffd.session import get_current_user, login_required, is_valid_session
 from uffd.database import db
 
@@ -48,17 +48,19 @@ def update(roleid=False):
 	role.description = request.values['description']
 
 	groups = Group.from_ldap_all()
-	role_group_dns = list(role.group_dns())
+	role_group_dns = role.group_dns()
 	for group in groups:
 		if request.values.get('group-{}'.format(group.gid), False):
 			if group.dn in role_group_dns:
 				continue
-			newmapping = RoleGroup()
-			newmapping.dn = group.dn
-			newmapping.role = role
-			session.add(newmapping)
+			role.add_group(group)
 		elif group.dn in role_group_dns:
-			session.delete(RoleGroup.query.filter_by(role_id=role.id, dn=group.dn).one())
+			role.del_group(group)
+
+#	usergroups = set()
+#	for role in Role.get_for_user(user).all():
+#		usergroups.update(role.group_dns())
+#	user.replace_group_dns(usergroups)
 
 	session.commit()
 	return redirect(url_for('role.index'))
diff --git a/uffd/user/__init__.py b/uffd/user/__init__.py
index 130d312f..3d56a568 100644
--- a/uffd/user/__init__.py
+++ b/uffd/user/__init__.py
@@ -1,5 +1,4 @@
 from .views_user import bp as bp_user
 from .views_group import bp as bp_group
-from .models import User, Group
 
 bp = [bp_user, bp_group]
diff --git a/uffd/user/models.py b/uffd/user/models.py
index 7da2cca0..42a0f12b 100644
--- a/uffd/user/models.py
+++ b/uffd/user/models.py
@@ -1,18 +1,24 @@
-from ldap3 import MODIFY_REPLACE, HASHED_SALTED_SHA512
+import secrets
+
+from ldap3 import MODIFY_REPLACE, MODIFY_DELETE, MODIFY_ADD, HASHED_SALTED_SHA512
 from ldap3.utils.hashed import hashed
 from flask import current_app
 
 from uffd import ldap
 
 class User():
-	def __init__(self, uid=None, loginname='', displayname='', mail='', groups=None):
+	def __init__(self, uid=None, loginname='', displayname='', mail='', groups=None, dn=None):
 		self.uid = uid
 		self.loginname = loginname
 		self.displayname = displayname
 		self.mail = mail
+		self.newpassword = None
+		self.dn = dn
+
 		self.groups_ldap = groups or []
+		self.initial_groups_ldap = groups or []
+		self.groups_changed = False
 		self._groups = None
-		self.newpassword = None
 
 	@classmethod
 	def from_ldap(cls, ldapobject):
@@ -21,7 +27,8 @@ class User():
 				loginname=ldapobject['uid'].value,
 				displayname=ldapobject['cn'].value,
 				mail=ldapobject['mail'].value,
-				groups=ldap.get_ldap_array_attribute_safe(ldapobject, 'memberOf')
+				groups=ldap.get_ldap_array_attribute_safe(ldapobject, 'memberOf'),
+				dn=ldapobject.entry_dn,
 			)
 
 	@classmethod
@@ -35,11 +42,13 @@ class User():
 	def to_ldap(self, new=False):
 		conn = ldap.get_conn()
 		if new:
+			self.uid = ldap.get_next_uid()
 			attributes = {
-				'uidNumber': ldap.get_next_uid(),
+				'uidNumber': self.uid,
 				'gidNumber': current_app.config['LDAP_USER_GID'],
 				'homeDirectory': '/home/'+self.loginname,
 				'sn': ' ',
+				'userPassword': hashed(HASHED_SALTED_SHA512, secrets.token_hex(128)),
 				# same as for update
 				'givenName': self.displayname,
 				'displayName': self.displayname,
@@ -59,6 +68,17 @@ class User():
 				attributes['userPassword'] = [(MODIFY_REPLACE, [hashed(HASHED_SALTED_SHA512, self.newpassword)])]
 			dn = ldap.uid_to_dn(self.uid)
 			result = conn.modify(dn, attributes)
+		self.dn = dn
+
+		group_conn = ldap.get_conn()
+		for group in self.initial_groups_ldap:
+			if not group in self.groups_ldap:
+				group_conn.modify(group, {'uniqueMember': [(MODIFY_DELETE, [self.dn])]})
+		for group in self.groups_ldap:
+			if not group in self.initial_groups_ldap:
+				group_conn.modify(group, {'uniqueMember': [(MODIFY_ADD, [self.dn])]})
+		self.groups_changed = False
+
 		return result
 
 	def get_groups(self):
@@ -71,6 +91,10 @@ class User():
 				groups.append(newgroup)
 		self._groups = groups
 		return groups
+	def replace_group_dns(self, values):
+		self._groups = None
+		self.groups_ldap = values
+		self.groups_changed = True
 
 	def is_in_group(self, name):
 		if not name:
diff --git a/uffd/user/templates/user.html b/uffd/user/templates/user.html
index 3ac0a02b..76b56005 100644
--- a/uffd/user/templates/user.html
+++ b/uffd/user/templates/user.html
@@ -3,7 +3,16 @@
 {% block body %}
 <form action="{{ url_for("user.update", uid=user.uid) }}" method="POST">
 <div class="align-self-center">
-	<ul class="nav nav-tabs " id="tablist" role="tablist">
+	<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("user.index") }}" class="btn btn-secondary">Cancel</a>
+		{% if user.uid %}
+			<a href="{{ url_for("user.delete", uid=user.uid) }}" 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="profile-tab" data-toggle="tab" href="#profile" role="tab" aria-controls="profile" aria-selected="true">Profile</a>
 		</li>
@@ -14,7 +23,7 @@
 			<a class="nav-link" id="ldif-tab" data-toggle="tab" href="#ldif" role="tab" aria-controls="ldif" aria-selected="false">LDIF</a>
 		</li>
 	</ul>
-	<div class="tab-content border border-top-0 mb-2 pt-2" id="tabcontent">
+	<div class="tab-content border mb-2 pt-2" id="tabcontent">
 		<div class="tab-pane fade show active" id="profile" role="tabpanel" aria-labelledby="roles-tab">
 			<div class="form-group col">
 				<label for="user-uid">uid</label>
@@ -60,6 +69,34 @@
 		<div class="tab-pane fade" id="roles" role="tabpanel" aria-labelledby="roles-tab">
 			<div class="form-group col">
 				<span>Roles:</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 role in roles %}
+						<tr id="role-{{ role.id }}">
+							<td>
+								<div class="form-check">
+									<input class="form-check-input" type="checkbox" id="role-{{ role.id }}-checkbox" name="role-{{ role.id }}" value="1" aria-label="enabled" {% if user.dn in role.member_dns() %}checked{% endif %}>
+								</div>
+							</td>
+							<td>
+								<a href="{{ url_for("role.show", roleid=role.id) }}">
+									{{ role.name }}
+								</a>
+							</td>
+							<td>
+								{{ role.description }}
+							</td>
+						</tr>
+						{% endfor %}
+					</tbody>
+				</table>
 			</div>
 			<div class="form-group col">
 				<span>Resulting groups (only updated after save):</span>
@@ -93,16 +130,6 @@
 			</div>
 		</div>
 	</div>
-
-	<div class="form-group col pl-0">
-		<button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> Save</button>
-		<a href="{{ url_for("user.index") }}" class="btn btn-secondary">Cancel</a>
-		{% if user.uid %}
-			<a href="{{ url_for("user.delete", uid=user.uid) }}" 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>
 </div>
 </form>
 {% endblock %}
diff --git a/uffd/user/templates/user_list.html b/uffd/user/templates/user_list.html
index bd5766fa..8bd88a19 100644
--- a/uffd/user/templates/user_list.html
+++ b/uffd/user/templates/user_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">uid</th>
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index 08547f9b..87923829 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -5,6 +5,8 @@ from uffd.csrf import csrf_protect
 from uffd.selfservice import send_passwordreset
 from uffd.ldap import get_conn, escape_filter_chars
 from uffd.session import login_required, is_valid_session, get_current_user
+from uffd.role.models import Role
+from uffd.database import db
 
 from .models import User
 
@@ -41,7 +43,7 @@ def show(uid=None):
 		assert len(conn.entries) == 1
 		user = User.from_ldap(conn.entries[0])
 		ldif = conn.entries[0].entry_to_ldif()
-	return render_template('user.html', user=user, user_ldif=ldif)
+	return render_template('user.html', user=user, user_ldif=ldif, roles=Role.query.all())
 
 @bp.route("/<int:uid>/update", methods=['POST'])
 @bp.route("/new", methods=['POST'])
@@ -60,23 +62,42 @@ def update(uid=False):
 		user = User.from_ldap(conn.entries[0])
 	if not user.set_mail(request.form['mail']):
 		flash('Mail is invalide.')
-		return redirect(url_for('.user_show'))
+		return redirect(url_for('user.show', uid=uid))
 	new_displayname = request.form['displayname'] if request.form['displayname'] else request.form['loginname']
 	if not user.set_displayname(new_displayname):
 		flash('Display name does not meet requirements')
-		return redirect(url_for('user.show'))
+		return redirect(url_for('user.show', uid=uid))
 	new_password = request.form.get('password')
 	if new_password and not is_newuser:
 		user.set_password(new_password)
+
+	session = db.session
+	roles = Role.query.all()
+	for role in roles:
+		role_member_dns = role.member_dns()
+		if request.values.get('role-{}'.format(role.id), False):
+			if user.dn in role_member_dns:
+				continue
+			role.add_member(user)
+		elif user.dn in role_member_dns:
+			role.del_member(user)
+	usergroups = set()
+	for role in Role.get_for_user(user).all():
+		usergroups.update(role.group_dns())
+	user.replace_group_dns(usergroups)
+
 	if user.to_ldap(new=is_newuser):
 		if is_newuser:
 			send_passwordreset(user.loginname)
 			flash('User created. We sent the user a password reset link by mail')
+			session.commit()
 		else:
 			flash('User updated')
+			session.commit()
 	else:
 		flash('Error updating user: {}'.format(conn.result['message']))
-	return redirect(url_for('user.index'))
+		session.rollback()
+	return redirect(url_for('user.show', uid=user.uid))
 
 @bp.route("/<int:uid>/del")
 @csrf_protect(blueprint=bp)
-- 
GitLab