From bb892bb1a49a5561f8e370b80d4131e268384334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9=20Tille?= Date: Sun, 25 Nov 2018 22:50:48 +0100 Subject: [PATCH] Implement group management --- locales/en.json | 35 +++++ src/yunohost/user.py | 342 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 346 insertions(+), 31 deletions(-) diff --git a/locales/en.json b/locales/en.json index 5ef9f5c0c..5b78cdcd7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -180,6 +180,7 @@ "dyndns_registration_failed": "Unable to register DynDNS domain: {error:s}", "dyndns_domain_not_provided": "Dyndns provider {provider:s} cannot provide domain {domain:s}.", "dyndns_unavailable": "Domain {domain:s} is not available.", + "edit_group_not_allowed": "You are not allowed to edit the group {group:s}", "executing_command": "Executing command '{command:s}'…", "executing_script": "Executing script '{script:s}'…", "extracting": "Extracting…", @@ -207,6 +208,18 @@ "global_settings_unknown_type": "Unexpected situation, the setting {setting:s} appears to have the type {unknown_type:s} but it's not a type supported by the system.", "good_practices_about_admin_password": "You are now about to define a new administration password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", "good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", + "group_alread_allowed": "Group '{group:s}' already allowed to access to permission '{permission:s}' for app '{app:s}'", + "group_alread_disallowed": "Group '{group:s}' already disallowed to access to permission '{permission:s}' for app '{app:s}'", + "group_name_already_exist": "Group {name:s} already exist", + "group_created": "Group creation success", + "group_creation_failed": "Group creation failed", + "group_deleted": "Group deleted", + "group_deletion_failed": "Group deletion failed", + "group_deletion_not_allowed": "You are not allowed to remove the main group of the user {user:s}", + "group_info_failed": "Group info failed", + "group_unknown": "Groupe {group:s} unknown", + "group_updated": "Groupe updated", + "group_update_failed": "groupe update failed", "hook_exec_failed": "Script execution failed: {path:s}", "hook_exec_not_terminated": "Script execution hasn\u2019t terminated: {path:s}", "hook_list_by_invalid": "Invalid property to list hook by", @@ -244,13 +257,20 @@ "log_dyndns_subscribe": "Subscribe to a YunoHost subdomain '{}'", "log_dyndns_update": "Update the ip associated with your YunoHost subdomain '{}'", "log_letsencrypt_cert_install": "Install Let's encrypt certificate on '{}' domain", + "log_permission_add": "Add permission '{}' for app '{}'", + "log_permission_remove": "Remove permission '{}'", + "log_permission_update": "Update permission '{}' for app '{}'", "log_selfsigned_cert_install": "Install self signed certificate on '{}' domain", "log_letsencrypt_cert_renew": "Renew '{}' Let's encrypt certificate", "log_service_enable": "Enable '{}' service", "log_service_regen_conf": "Regenerate system configurations '{}'", "log_user_create": "Add '{}' user", "log_user_delete": "Delete '{}' user", + "log_user_group_add": "Add '{}' group", + "log_user_group_delete": "Delete '{}' group", + "log_user_group_update": "Update '{}' group", "log_user_update": "Update information of '{}' user", + "log_user_permission_add": "Update '{}' permission", "log_tools_maindomain": "Make '{}' as main domain", "log_tools_migrations_migrate_forward": "Migrate forward", "log_tools_migrations_migrate_backward": "Migrate backward", @@ -370,11 +390,23 @@ "pattern_port_or_range": "Must be a valid port number (i.e. 0-65535) or range of ports (e.g. 100:200)", "pattern_positive_number": "Must be a positive number", "pattern_username": "Must be lower-case alphanumeric and underscore characters only", + "permission_already_clear": "Permission '{permission:s}' already clear for app {app:s}", + "permission_already_exist": "Permission '{permission:s}' for app {app:s} already exist", + "permission_created": "Permission '{permission:s}' for app {app:s} created", + "premission_creation_failled": "Permission creation failed", + "permission_deleted": "Permission '{permission:s}' for app {app:s} deleted", + "permission_deletion_failed": "Permission '{permission:s}' for app {app:s} deletion failed", + "permission_not_found": "Permission '{permission:s}' not found for application {app:s}", + "permission_name_not_valid": "Permission name '{permission:s}' not valid", + "permission_update_failed": "Permission update failed", + "permission_updated": "Permission '{permission:s}' for app {app:s} updated", "port_already_closed": "Port {port:d} is already closed for {ip_version:s} connections", "port_already_opened": "Port {port:d} is already opened for {ip_version:s} connections", "port_available": "Port {port:d} is available", "port_unavailable": "Port {port:d} is not available", "recommend_to_add_first_user": "The post-install is finished but YunoHost needs at least one user to work correctly, you should add one using 'yunohost user create' or the admin interface.", + "remove_main_permission_not_allowed": "Removing the main permission is not allowed", + "remove_user_of_group_not_allowed": "You are not allowed to remove the user {user:s} in the group {group:s}", "restore_action_required": "You must specify something to restore", "restore_already_installed_app": "An app is already installed with the id '{app:s}'", "restore_app_failed": "Unable to restore the app '{app:s}'", @@ -459,6 +491,7 @@ "ssowat_conf_updated": "The SSOwat configuration has been updated", "ssowat_persistent_conf_read_error": "Error while reading SSOwat persistent configuration: {error:s}. Edit /etc/ssowat/conf.json.persistent file to fix the JSON syntax", "ssowat_persistent_conf_write_error": "Error while saving SSOwat persistent configuration: {error:s}. Edit /etc/ssowat/conf.json.persistent file to fix the JSON syntax", + "system_groupname_exists": "Groupname already exists in the system group", "system_upgraded": "The system has been upgraded", "system_username_exists": "Username already exists in the system users", "unbackup_app": "App '{app:s}' will not be saved", @@ -474,12 +507,14 @@ "upnp_disabled": "UPnP has been disabled", "upnp_enabled": "UPnP has been enabled", "upnp_port_open_failed": "Unable to open UPnP ports", + "user_alread_in_group": "User {user:} already in group {group:s}", "user_created": "The user has been created", "user_creation_failed": "Unable to create user", "user_deleted": "The user has been deleted", "user_deletion_failed": "Unable to delete user", "user_home_creation_failed": "Unable to create user home folder", "user_info_failed": "Unable to retrieve user information", + "user_not_in_group": "User {user:s} not in group {group:s}", "user_unknown": "Unknown user: {user:s}", "user_update_failed": "Unable to update user", "user_updated": "The user has been updated", diff --git a/src/yunohost/user.py b/src/yunohost/user.py index a38f0b4c5..b4790926b 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -26,6 +26,7 @@ import os import re import pwd +import grp import json import crypt import random @@ -123,7 +124,8 @@ def user_create(operation_logger, auth, username, firstname, lastname, mail, pas # Validate uniqueness of username and mail in LDAP auth.validate_uniqueness({ 'uid': username, - 'mail': mail + 'mail': mail, + 'cn': username }) # Validate uniqueness of username in system users @@ -150,7 +152,7 @@ def user_create(operation_logger, auth, username, firstname, lastname, mail, pas # Get random UID/GID all_uid = {x.pw_uid for x in pwd.getpwall()} - all_gid = {x.pw_gid for x in pwd.getpwall()} + all_gid = {x.gr_gid for x in grp.getgrall()} uid_guid_found = False while not uid_guid_found: @@ -160,7 +162,7 @@ def user_create(operation_logger, auth, username, firstname, lastname, mail, pas # Adapt values for LDAP fullname = '%s %s' % (firstname, lastname) attr_dict = { - 'objectClass': ['mailAccount', 'inetOrgPerson', 'posixAccount'], + 'objectClass': ['mailAccount', 'inetOrgPerson', 'posixAccount', 'userPermissionYnh'], 'givenName': firstname, 'sn': lastname, 'displayName': fullname, @@ -201,25 +203,26 @@ def user_create(operation_logger, auth, username, firstname, lastname, mail, pas # Invalidate passwd to take user creation into account subprocess.call(['nscd', '-i', 'passwd']) - # Update SFTP user group - memberlist = auth.search(filter='cn=sftpusers', attrs=['memberUid'])[0]['memberUid'] - memberlist.append(username) - if auth.update('cn=sftpusers,ou=groups', {'memberUid': memberlist}): - try: - # Attempt to create user home folder - subprocess.check_call( - ['su', '-', username, '-c', "''"]) - except subprocess.CalledProcessError: - if not os.path.isdir('/home/{0}'.format(username)): - logger.warning(m18n.n('user_home_creation_failed'), - exc_info=1) - app_ssowatconf(auth) - # TODO: Send a welcome mail to user - logger.success(m18n.n('user_created')) - hook_callback('post_user_create', - args=[username, mail, password, firstname, lastname]) + try: + # Attempt to create user home folder + subprocess.check_call( + ['su', '-', username, '-c', "''"]) + except subprocess.CalledProcessError: + if not os.path.isdir('/home/{0}'.format(username)): + logger.warning(m18n.n('user_home_creation_failed'), + exc_info=1) + app_ssowatconf(auth) + # TODO: Send a welcome mail to user + logger.success(m18n.n('user_created')) + # Create group for user and add to group 'ALL' + user_group_add(auth, groupname=username, gid=uid) + user_group_update(auth, groupname=username, add_user=username, force=True) + user_group_update(auth, 'ALL', add_user=username, force=True) - return {'fullname': fullname, 'username': username, 'mail': mail} + hook_callback('post_user_create', + args=[username, mail, password, firstname, lastname]) + + return {'fullname': fullname, 'username': username, 'mail': mail} raise YunohostError('user_creation_failed') @@ -242,19 +245,24 @@ def user_delete(operation_logger, auth, username, purge=False): # Invalidate passwd to take user deletion into account subprocess.call(['nscd', '-i', 'passwd']) - # Update SFTP user group - memberlist = auth.search(filter='cn=sftpusers', attrs=['memberUid'])[0]['memberUid'] - try: - memberlist.remove(username) - except: - pass - if auth.update('cn=sftpusers,ou=groups', {'memberUid': memberlist}): - if purge: - subprocess.call(['rm', '-rf', '/home/{0}'.format(username)]) - subprocess.call(['rm', '-rf', '/var/mail/{0}'.format(username)]) + if purge: + subprocess.call(['rm', '-rf', '/home/{0}'.format(username)]) else: raise YunohostError('user_deletion_failed') + user_group_delete(auth, username, force=True) + + group_list = auth.search('ou=groups,dc=yunohost,dc=org', + '(&(objectclass=groupOfNamesYnh)(memberUid=%s))' + % username, ['cn']) + for group in group_list: + user_list = auth.search('ou=groups,dc=yunohost,dc=org', + 'cn=' + group['cn'][0], + ['memberUid'])[0] + user_list['memberUid'].remove(username) + if not auth.update('cn=%s,ou=groups' % group['cn'][0], user_list): + raise YunohostError('group_update_failed') + app_ssowatconf(auth) hook_callback('post_user_delete', args=[username, purge]) @@ -462,6 +470,278 @@ def user_info(auth, username): else: raise YunohostError('user_info_failed') +# +# Group subcategory +# +# +def user_group_list(auth, fields=None): + """ + List users + + Keyword argument: + filter -- LDAP filter used to search + offset -- Starting number for user fetching + limit -- Maximum number of user fetched + fields -- fields to fetch + + """ + group_attr = { + 'cn' : 'groupname', + 'member' : 'members', + 'permission' : 'permission' + } + attrs = ['cn'] + groups = {} + + if fields: + keys = group_attr.keys() + for attr in fields: + if attr in keys: + attrs.append(attr) + else: + raise MoulinetteError(errno.EINVAL, + m18n.n('field_invalid', attr)) + else: + attrs = ['cn', 'member'] + + result = auth.search('ou=groups,dc=yunohost,dc=org', + '(objectclass=groupOfNamesYnh)', + attrs) + + for group in result: + # The group "admins" should be hidden for the user + if group_attr['cn'] == "admins": + continue + entry = {} + for attr, values in group.items(): + if values: + if attr == "member": + entry[group_attr[attr]] = [] + for v in values: + entry[group_attr[attr]].append(v.split("=")[1].split(",")[0]) + elif attr == "permission": + entry[group_attr[attr]] = {} + for v in values: + permission = v.split("=")[1].split(",")[0].split(".")[0] + pType = v.split("=")[1].split(",")[0].split(".")[1] + if permission in entry[group_attr[attr]]: + entry[group_attr[attr]][permission].append(pType) + else: + entry[group_attr[attr]][permission] = [pType] + else: + entry[group_attr[attr]] = values[0] + + groupname = entry[group_attr['cn']] + groups[groupname] = entry + return {'groups' : groups} + + +@is_unit_operation([('groupname', 'user')]) +def user_group_add(operation_logger, auth, groupname,gid=None): + """ + Create group + + Keyword argument: + groupname -- Must be unique + + """ + from yunohost.app import app_ssowatconf + from yunohost.permission import _permission_sync_to_user + + operation_logger.start() + + # Validate uniqueness of groupname in LDAP + conflict = auth.get_conflict({ + 'cn': groupname + }, base_dn='ou=groups,dc=yunohost,dc=org') + if conflict: + raise MoulinetteError(errno.EEXIST, m18n.n('group_name_already_exist', name=groupname)) + + # Validate uniqueness of groupname in system group + all_existing_groupnames = {x.gr_name for x in grp.getgrall()} + if groupname in all_existing_groupnames: + raise MoulinetteError(errno.EEXIST, m18n.n('system_groupname_exists')) + + if not gid: + # Get random GID + all_gid = {x.gr_gid for x in grp.getgrall()} + + uid_guid_found = False + while not uid_guid_found: + gid = str(random.randint(200, 99999)) + uid_guid_found = gid not in all_gid + + attr_dict = { + 'objectClass': ['top', 'groupOfNamesYnh', 'posixGroup'], + 'cn': groupname, + 'gidNumber': gid, + } + + if auth.add('cn=%s,ou=groups' % groupname, attr_dict): + _permission_sync_to_user(auth) + app_ssowatconf(auth) + logger.success(m18n.n('group_created')) + return {'name': groupname} + + raise MoulinetteError(169, m18n.n('group_creation_failed')) + + +@is_unit_operation([('groupname', 'user')]) +def user_group_delete(operation_logger, auth, groupname, force=False): + """ + Delete user + + Keyword argument: + groupname -- Groupname to delete + + """ + from yunohost.app import app_ssowatconf + from yunohost.permission import _permission_sync_to_user + + if not force and (groupname == 'ALL' or groupname == 'admins' or groupname in user_list(auth, ['uid'])['users']): + raise MoulinetteError(errno.EPERM, m18n.n('group_deletion_not_allowed', user=groupname)) + + operation_logger.start() + if not auth.remove('cn=%s,ou=groups' % groupname): + raise MoulinetteError(169, m18n.n('group_deletion_failed')) + + _permission_sync_to_user(auth) + app_ssowatconf(auth) + logger.success(m18n.n('group_deleted')) + + +@is_unit_operation([('groupname', 'user')]) +def user_group_update(operation_logger, auth, groupname, add_user=None, remove_user=None, force=False): + """ + Update user informations + + Keyword argument: + groupname -- Groupname to update + add_user -- User to add in group + remove_user -- User to remove in group + + """ + + from yunohost.app import app_ssowatconf + from yunohost.permission import _permission_sync_to_user + + attrs_to_fetch = ['member'] + + if (groupname == 'ALL' or groupname == 'admins') and not force: + raise MoulinetteError(errno.EINVAL, m18n.n('edit_group_not_allowed', group=groupname)) + + # Populate group informations + result = auth.search(base='ou=groups,dc=yunohost,dc=org', + filter='cn=' + groupname, attrs=attrs_to_fetch) + if not result: + raise MoulinetteError(errno.EINVAL, m18n.n('group_unknown', group=groupname)) + group = result[0] + + new_group_list = {'member': set(), 'memberUid': set()} + if 'member' in group: + new_group_list['member'] = set(group['member']) + else: + group['member'] = [] + + user_l = user_list(auth, ['uid'])['users'] + + if add_user: + if not isinstance(add_user, list): + add_user = [add_user] + for user in add_user: + if not user in user_l: + raise MoulinetteError(errno.EINVAL, m18n.n('user_unknown', user=user)) + userDN = "uid=" + user + ",ou=users,dc=yunohost,dc=org" + if userDN in group['member']: + logger.warning(m18n.n('user_alread_in_group', user=user, group=groupname)) + new_group_list['member'].add(userDN) + + if remove_user: + if not isinstance(remove_user, list): + remove_user = [remove_user] + for user in remove_user: + userDN = "uid=" + user + ",ou=users,dc=yunohost,dc=org" + if user == groupname: + raise MoulinetteError(errno.EINVAL, + m18n.n('remove_user_of_group_not_allowed', user=user, group=groupname)) + if 'member' in group and userDN in group['member']: + new_group_list['member'].remove(userDN) + else: + logger.warning(m18n.n('user_not_in_group', user=user, group=groupname)) + + # Sychronise memberUid with member (to keep the posix group structure) + # In posixgroup the main group of each user is only written in the gid number of the user + for member in new_group_list['member']: + member_Uid = member.split("=")[1].split(",")[0] + # Don't add main user in the group. + # Note that in the Unix system the main user of the group is linked by the gid in the user attribute. + # So the main user need to be not in the memberUid list of his own group. + if member_Uid != groupname: + new_group_list['memberUid'].add(member_Uid) + + operation_logger.start() + + if new_group_list['member'] != set(group['member']): + if not auth.update('cn=%s,ou=groups' % groupname, new_group_list): + raise MoulinetteError(169, m18n.n('group_update_failed')) + + _permission_sync_to_user(auth) + logger.success(m18n.n('group_updated')) + app_ssowatconf(auth) + return user_group_info(auth, groupname) + + +def user_group_info(auth, groupname): + """ + Get user informations + + Keyword argument: + groupname -- Groupname to get informations + + """ + group_attrs = [ + 'cn', 'member', 'permission' + ] + result = auth.search('ou=groups,dc=yunohost,dc=org', "cn=" + groupname, group_attrs) + + if not result: + raise MoulinetteError(errno.EINVAL, m18n.n('group_unknown', group=groupname)) + else: + group = result[0] + + result_dict = { + 'groupname': group['cn'][0], + 'member': None + } + if 'member' in group: + result_dict['member'] = {m.split("=")[1].split(",")[0] for m in group['member']} + return result_dict + +# +# Permission subcategory +# +# +import yunohost.permission + +def user_permission_list(auth, app=None, permission=None, username=None, group=None): + return yunohost.permission.user_permission_list(auth, app, permission, username, group) + +@is_unit_operation([('app', 'user')]) +def user_permission_add(operation_logger, auth, app, permission="main", username=None, group=None): + return yunohost.permission.user_permission_update(operation_logger, auth, app, permission=permission, + add_username=username, add_group=group, + del_username=None, del_group=None) + +@is_unit_operation([('app', 'user')]) +def user_permission_remove(operation_logger, auth, app, permission="main", username=None, group=None): + return yunohost.permission.user_permission_update(operation_logger, auth, app, permission=permission, + add_username=None, add_group=None, + del_username=username, del_group=group) + +@is_unit_operation([('app', 'user')]) +def user_permission_clear(operation_logger, auth, app, permission=None): + return yunohost.permission.user_permission_clear(operation_logger, auth, app, permission) + # # SSH subcategory #