From 193a2eff0a730dca7187fff43e8f28e0cc065ca0 Mon Sep 17 00:00:00 2001
From: Julian Rother <julianr@fsmpi.rwth-aachen.de>
Date: Sat, 12 Jun 2021 18:59:54 +0200
Subject: [PATCH] Add service users, closes #55

Co-authored-by: psy <psy@darmstadt.ccc.de>
---
 tests/test_user.py                 | 32 +++++++++++++++++++++++++++++-
 uffd/default_config.cfg            |  2 ++
 uffd/user/models.py                | 29 +++++++++++++++++++++------
 uffd/user/templates/user.html      | 19 +++++++++++++++---
 uffd/user/templates/user_list.html |  5 ++++-
 uffd/user/views_user.py            | 13 +++++++++---
 6 files changed, 86 insertions(+), 14 deletions(-)

diff --git a/tests/test_user.py b/tests/test_user.py
index 8fae44db..baedf5bc 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -87,16 +87,46 @@ class TestUserViews(UffdTestCase):
 		user = User.query.get('uid=newuser,ou=users,dc=example,dc=com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertIsNotNone(user)
+		self.assertFalse(user.is_service_user)
 		self.assertEqual(user.loginname, 'newuser')
 		self.assertEqual(user.displayname, 'New User')
 		self.assertEqual(user.mail, 'newuser@example.com')
 		self.assertTrue(user.uid)
 		role1 = Role(name='role1')
-		print('test_new', role1.db_members, role1.members, user.roles)
 		self.assertEqual(roles, ['base', 'role1'])
 		# TODO: confirm Mail is send, login not yet possible
 		#self.assertTrue(ldap.test_user_bind(user.dn, 'newpassword'))
 
+	def test_new_service(self):
+		db.session.add(Role(name='base'))
+		role1 = Role(name='role1')
+		db.session.add(role1)
+		role2 = Role(name='role2')
+		db.session.add(role2)
+		db.session.commit()
+		role1_id = role1.id
+		r = self.client.get(path=url_for('user.show'), follow_redirects=True)
+		dump('user_new_service', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIsNone(User.query.get('uid=newuser,ou=users,dc=example,dc=com'))
+		r = self.client.post(path=url_for('user.update'),
+			data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			f'role-{role1_id}': '1', 'password': 'newpassword', 'serviceaccount': '1'}, follow_redirects=True)
+		dump('user_new_submit', r)
+		self.assertEqual(r.status_code, 200)
+		user = User.query.get('uid=newuser,ou=users,dc=example,dc=com')
+		roles = sorted([r.name for r in user.roles])
+		self.assertIsNotNone(user)
+		self.assertTrue(user.is_service_user)
+		self.assertEqual(user.loginname, 'newuser')
+		self.assertEqual(user.displayname, 'New User')
+		self.assertEqual(user.mail, 'newuser@example.com')
+		self.assertTrue(user.uid)
+		role1 = Role(name='role1')
+		self.assertEqual(roles, ['role1'])
+		# TODO: confirm Mail is send, login not yet possible
+		#self.assertTrue(ldap.test_user_bind(user.dn, 'newpassword'))
+
 	def test_new_invalid_loginname(self):
 		r = self.client.post(path=url_for('user.update'),
 			data={'loginname': '!newuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 55962602..022657fe 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -3,6 +3,8 @@ LDAP_USER_SEARCH_FILTER=[("objectClass", "person")]
 LDAP_USER_OBJECTCLASSES=["top", "inetOrgPerson", "organizationalPerson", "person", "posixAccount"]
 LDAP_USER_MIN_UID=10000
 LDAP_USER_MAX_UID=18999
+LDAP_USER_SERVICE_MIN_UID=19000
+LDAP_USER_SERVICE_MAX_UID=19999
 LDAP_USER_GID=20001
 LDAP_USER_DN_ATTRIBUTE="uid"
 LDAP_USER_UID_ATTRIBUTE="uidNumber"
diff --git a/uffd/user/models.py b/uffd/user/models.py
index c0ce603e..2749884f 100644
--- a/uffd/user/models.py
+++ b/uffd/user/models.py
@@ -8,13 +8,18 @@ from ldap3.utils.hashed import hashed, HASHED_SALTED_SHA512
 from uffd.ldap import ldap
 from uffd.lazyconfig import lazyconfig_str, lazyconfig_list
 
-def get_next_uid():
-	max_uid = current_app.config['LDAP_USER_MIN_UID']
+def get_next_uid(service=False):
+	if service:
+		new_uid_min = current_app.config['LDAP_USER_SERVICE_MIN_UID']
+		new_uid_max = current_app.config['LDAP_USER_SERVICE_MAX_UID']
+	else:
+		new_uid_min = current_app.config['LDAP_USER_MIN_UID']
+		new_uid_max = current_app.config['LDAP_USER_MAX_UID']
+	next_uid = new_uid_min
 	for user in User.query.all():
-		if user.uid <= current_app.config['LDAP_USER_MAX_UID']:
-			max_uid = max(user.uid, max_uid)
-	next_uid = max_uid + 1
-	if next_uid > current_app.config['LDAP_USER_MAX_UID']:
+		if user.uid <= new_uid_max:
+			next_uid = max(next_uid, user.uid + 1)
+	if next_uid > new_uid_max:
 		raise Exception('No free uid found')
 	return next_uid
 
@@ -50,6 +55,18 @@ class BaseUser(ldap.Model):
 	def group_dns(self):
 		return [group.dn for group in self.groups]
 
+	@property
+	def is_service_user(self):
+		if self.uid is None:
+			return None
+		return self.uid >= current_app.config['LDAP_USER_SERVICE_MIN_UID'] and self.uid <= current_app.config['LDAP_USER_SERVICE_MAX_UID']
+
+	@is_service_user.setter
+	def is_service_user(self, value):
+		assert self.uid is None
+		if value:
+			self.uid = get_next_uid(service=True)
+
 	def add_default_attributes(self):
 		for name, values in current_app.config['LDAP_USER_DEFAULT_ATTRIBUTES'].items():
 			if self.ldap_object.getattr(name):
diff --git a/uffd/user/templates/user.html b/uffd/user/templates/user.html
index b2feedf9..d638f7fc 100644
--- a/uffd/user/templates/user.html
+++ b/uffd/user/templates/user.html
@@ -27,13 +27,26 @@
 	<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>
+				<label for="user-uid">
+					uid
+					{% if user.is_service_user %}
+					<span class="badge badge-secondary">service</span>
+					{% endif %}
+				</label>
 				{% if user.uid %}
 				<input type="number" class="form-control" id="user-uid" name="uid" value="{{ user.uid or '' }}" readonly>
 				{% else %}
 				<input type="text" class="form-control" id="user-uid" name="uid" placeholder="will be choosen" readonly>
 				{% endif %}
 			</div>
+			{% if not user.uid %}
+			<div class="form-group col">
+				<div class="form-check">
+					<input class="form-check-input" type="checkbox" id="user-serviceaccount" name="serviceaccount" value="1" aria-label="enabled">
+					<label class="form-check-label" for="user-serviceaccount">Service User</label>
+				</div>
+			</div>
+			{% endif %}
 			<div class="form-group col">
 				<label for="user-loginname">Login Name</label>
 				<input type="text" class="form-control" id="user-loginname" name="loginname" value="{{ user.loginname or '' }}" {% if user.uid %}readonly{% endif %}>
@@ -92,8 +105,8 @@
 							<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 in role.members or role.name in config["ROLES_BASEROLES"] %}checked {% endif %}
-										{% if role.name in config["ROLES_BASEROLES"] %}disabled {% endif %}>
+										{% if user in role.members %}checked {% endif %}
+										{% if role.name in config["ROLES_BASEROLES"] and not user.is_service_user %}disabled {% endif %}>
 								</div>
 							</td>
 							<td>
diff --git a/uffd/user/templates/user_list.html b/uffd/user/templates/user_list.html
index ddcaab3e..93d7da52 100644
--- a/uffd/user/templates/user_list.html
+++ b/uffd/user/templates/user_list.html
@@ -30,12 +30,15 @@
 						<a href="{{ url_for("user.show", uid=user.uid) }}">
 							{{ user.loginname }}
 						</a>
+						{% if user.is_service_user %}
+						<span class="badge badge-secondary">service</span>
+						{% endif %}
 					</td>
 					<td>
 						{{ user.displayname }}
 					</td>
 					<td>
-					{% for role in user.roles|sort(attribute="name") if not role.name in config["ROLES_BASEROLES"] %}
+					{% for role in user.roles|sort(attribute="name") if not role.name in config["ROLES_BASEROLES"] or user.is_service_user %}
 						<a href="{{ url_for("role.show", roleid=role.id) }}">{{ role.name }}</a>{% if not loop.last %}, {% endif %}
 					{% endfor %}
 					</td>
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index e25f89e7..71f75d8e 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -42,6 +42,8 @@ def update(uid=None):
 	if uid is None:
 		user = User()
 		ignore_blacklist = request.form.get('ignore-loginname-blacklist', False)
+		if request.form.get('serviceaccount'):
+			user.is_service_user = True
 		if not user.set_loginname(request.form['loginname'], ignore_blacklist=ignore_blacklist):
 			flash('Login name does not meet requirements')
 			return redirect(url_for('user.show'))
@@ -60,14 +62,19 @@ def update(uid=None):
 	ldap.session.add(user)
 	user.roles.clear()
 	for role in Role.query.all():
-		if request.values.get('role-{}'.format(role.id), False) or role.name in current_app.config["ROLES_BASEROLES"]:
+		if request.values.get('role-{}'.format(role.id), False):
+			user.roles.add(role)
+		elif not user.is_service_user and role.name in current_app.config["ROLES_BASEROLES"]:
 			user.roles.add(role)
 	user.update_groups()
 	ldap.session.commit()
 	db.session.commit()
 	if uid is None:
-		send_passwordreset(user, new=True)
-		flash('User created. We sent the user a password reset link by mail')
+		if user.is_service_user:
+			flash('Service user created')
+		else:
+			send_passwordreset(user, new=True)
+			flash('User created. We sent the user a password reset link by mail')
 	else:
 		flash('User updated')
 	return redirect(url_for('user.show', uid=user.uid))
-- 
GitLab