From 0f9d9388536022db1d9689256aa35c121b1170a1 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Thu, 1 Dec 2022 21:34:36 +0100 Subject: [PATCH] groups: add mail-aliases management (#1539) --- conf/slapd/db_init.ldif | 7 -- locales/en.json | 4 +- share/actionsmap.yml | 29 +++++ src/domain.py | 5 + src/migrations/0026_new_admins_group.py | 9 +- src/user.py | 152 ++++++++++++++++++------ 6 files changed, 157 insertions(+), 49 deletions(-) diff --git a/conf/slapd/db_init.ldif b/conf/slapd/db_init.ldif index 95b9dd936..8703afb85 100644 --- a/conf/slapd/db_init.ldif +++ b/conf/slapd/db_init.ldif @@ -47,14 +47,7 @@ dn: cn=admins,ou=groups,dc=yunohost,dc=org objectClass: posixGroup objectClass: top objectClass: groupOfNamesYnh -objectClass: mailGroup gidNumber: 4001 -mail: root -mail: admin -mail: admins -mail: webmaster -mail: postmaster -mail: abuse cn: admins dn: cn=all_users,ou=groups,dc=yunohost,dc=org diff --git a/locales/en.json b/locales/en.json index d18f8791e..6cd44780f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -446,6 +446,8 @@ "group_unknown": "The group '{group}' is unknown", "group_update_failed": "Could not update the group '{group}': {error}", "group_updated": "Group '{group}' updated", + "group_update_aliases": "Updating aliases for group '{group}'", + "group_no_change": "Nothing to change for group '{group}'", "group_user_already_in_group": "User {user} is already in group {group}", "group_user_not_in_group": "User {user} is not in group {group}", "hook_exec_failed": "Could not run script: {path}", @@ -519,7 +521,7 @@ "mail_alias_remove_failed": "Could not remove e-mail alias '{mail}'", "mail_domain_unknown": "Invalid e-mail address for domain '{domain}'. Please, use a domain administrated by this server.", "mail_forward_remove_failed": "Could not remove e-mail forwarding '{mail}'", - "mail_unavailable": "This e-mail address is reserved and shall be automatically allocated to the very first user", + "mail_unavailable": "This e-mail address is reserved for the admins group", "mailbox_disabled": "E-mail turned off for user {user}", "mailbox_used_space_dovecot_down": "The Dovecot mailbox service needs to be up if you want to fetch used mailbox space", "main_domain_change_failed": "Unable to change the main domain", diff --git a/share/actionsmap.yml b/share/actionsmap.yml index 98ae59a7b..1e482212b 100644 --- a/share/actionsmap.yml +++ b/share/actionsmap.yml @@ -321,6 +321,35 @@ user: extra: pattern: *pattern_username + add-mailalias: + action_help: Add mail aliases to group + api: PUT /users/groups//aliases/ + arguments: + groupname: + help: Name of the group to add user(s) to + extra: + pattern: *pattern_groupname + aliases: + help: Mail aliases to add + nargs: "+" + metavar: MAIL + extra: + pattern: *pattern_email + remove-mailalias: + action_help: Remove mail aliases to group + api: DELETE /users/groups//aliases/ + arguments: + groupname: + help: Name of the group to add user(s) to + extra: + pattern: *pattern_groupname + aliases: + help: Mail aliases to remove + nargs: "+" + metavar: MAIL + + + permission: subcategory_help: Manage permissions actions: diff --git a/src/domain.py b/src/domain.py index d24f44ddd..8a874687f 100644 --- a/src/domain.py +++ b/src/domain.py @@ -445,6 +445,8 @@ def domain_main_domain(operation_logger, new_main_domain=None): if not new_main_domain: return {"current_main_domain": _get_maindomain()} + old_main_domain = _get_maindomain() + # Check domain exists _assert_domain_exists(new_main_domain) @@ -468,6 +470,9 @@ def domain_main_domain(operation_logger, new_main_domain=None): if os.path.exists("/etc/yunohost/installed"): regen_conf() + from yunohost.user import _update_admins_group_aliases + _update_admins_group_aliases(old_main_domain=old_main_domain, new_main_domain=new_main_domain) + logger.success(m18n.n("main_domain_changed")) diff --git a/src/migrations/0026_new_admins_group.py b/src/migrations/0026_new_admins_group.py index 3c0702dcf..8060610bb 100644 --- a/src/migrations/0026_new_admins_group.py +++ b/src/migrations/0026_new_admins_group.py @@ -22,10 +22,12 @@ class MyMigration(Migration): @Migration.ldap_migration def run(self, *args): - from yunohost.user import user_list, user_info, user_group_update, user_update + from yunohost.user import user_list, user_info, user_group_update, user_update, user_group_add_mailalias, ADMIN_ALIASES from yunohost.utils.ldap import _get_ldap_interface from yunohost.permission import permission_sync_to_user + from yunohost.domain import _get_maindomain + main_domain = _get_maindomain() ldap = _get_ldap_interface() all_users = user_list()["users"].keys() @@ -87,12 +89,13 @@ class MyMigration(Migration): "cn=admins,ou=groups", { "cn": ["admins"], - "objectClass": ["top", "posixGroup", "groupOfNamesYnh", "mailGroup"], + "objectClass": ["top", "posixGroup", "groupOfNamesYnh"], "gidNumber": ["4001"], - "mail": ["root", "admin", "admins", "webmaster", "postmaster", "abuse"], }, ) + user_group_add_mailalias("admins", [f"{alias}@{main_domain}" for alias in ADMIN_ALIASES]) + permission_sync_to_user() if new_admin_user: diff --git a/src/user.py b/src/user.py index 84923106c..f177e8f93 100644 --- a/src/user.py +++ b/src/user.py @@ -49,7 +49,7 @@ FIELDS_FOR_IMPORT = { "groups": r"^|([a-z0-9_]+(,?[a-z0-9_]+)*)$", } -ADMIN_ALIASES = ["root@", "admin@", "admins", "webmaster@", "postmaster@", "abuse@"] +ADMIN_ALIASES = ["root", "admin", "admins", "webmaster", "postmaster", "abuse"] def user_list(fields=None): @@ -209,11 +209,7 @@ def user_create( if username in all_existing_usernames: raise YunohostValidationError("system_username_exists") - main_domain = _get_maindomain() - # FIXME: should forbit root@any.domain, not just main domain? - admin_aliases = [alias + main_domain for alias in ADMIN_ALIASES] - - if mail in admin_aliases: + if mail.split("@")[0] in ADMIN_ALIASES: raise YunohostValidationError("mail_unavailable") if not from_import: @@ -377,7 +373,7 @@ def user_update( " ".join(fullname.split()[1:]) or " " ) # Stupid hack because LDAP requires the sn/lastname attr, but it accepts a single whitespace... - from yunohost.domain import domain_list, _get_maindomain + from yunohost.domain import domain_list from yunohost.app import app_ssowatconf from yunohost.utils.password import ( assert_password_is_strong_enough, @@ -443,8 +439,6 @@ def user_update( env_dict["YNH_USER_PASSWORD"] = change_password if mail: - main_domain = _get_maindomain() - # If the requested mail address is already as main address or as an alias by this user if mail in user["mail"]: user["mail"].remove(mail) @@ -459,9 +453,7 @@ def user_update( "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] ) - # FIXME: should also forbid root@any.domain and not just the main domain - aliases = [alias + main_domain for alias in ADMIN_ALIASES] - if mail in aliases: + if mail.split("@")[0] in ADMIN_ALIASES: raise YunohostValidationError("mail_unavailable") new_attr_dict["mail"] = [mail] + user["mail"][1:] @@ -470,6 +462,9 @@ def user_update( if not isinstance(add_mailalias, list): add_mailalias = [add_mailalias] for mail in add_mailalias: + if mail.split("@")[0] in ADMIN_ALIASES: + raise YunohostValidationError("mail_unavailable") + # (c.f. similar stuff as before) if mail in user["mail"]: user["mail"].remove(mail) @@ -1109,22 +1104,15 @@ def user_group_update( groupname, add=None, remove=None, + add_mailalias=None, + remove_mailalias=None, force=False, sync_perm=True, from_import=False, ): - """ - Update user informations - - Keyword argument: - groupname -- Groupname to update - add -- User(s) to add in group - remove -- User(s) to remove in group - - """ from yunohost.permission import permission_sync_to_user - from yunohost.utils.ldap import _get_ldap_interface + from yunohost.utils.ldap import _get_ldap_interface, _ldap_path_extract existing_users = list(user_list()["users"].keys()) @@ -1141,53 +1129,119 @@ def user_group_update( "group_cannot_edit_primary_group", group=groupname ) + ldap = _get_ldap_interface() + + # Fetch info for this group + result = ldap.search( + "ou=groups", + "cn=" + groupname, + ["cn", "member", "permission", "mail", "objectClass"], + ) + + if not result: + raise YunohostValidationError("group_unknown", group=groupname) + + group = result[0] + # We extract the uid for each member of the group to keep a simple flat list of members - current_group = user_group_info(groupname)["members"] - new_group = copy.copy(current_group) + current_group_mail = group.get("mail", []) + new_group_mail = copy.copy(current_group_mail) + current_group_members = [_ldap_path_extract(p, "uid") for p in group.get("member", [])] + new_group_members = copy.copy(current_group_members) + new_attr_dict = {} if add: + users_to_add = [add] if not isinstance(add, list) else add for user in users_to_add: if user not in existing_users: raise YunohostValidationError("user_unknown", user=user) - if user in current_group: + if user in current_group_members: logger.warning( m18n.n("group_user_already_in_group", user=user, group=groupname) ) else: operation_logger.related_to.append(("user", user)) - new_group += users_to_add + new_group_members += users_to_add if remove: users_to_remove = [remove] if not isinstance(remove, list) else remove for user in users_to_remove: - if user not in current_group: + if user not in current_group_members: logger.warning( m18n.n("group_user_not_in_group", user=user, group=groupname) ) else: operation_logger.related_to.append(("user", user)) - # Remove users_to_remove from new_group - # Kinda like a new_group -= users_to_remove - new_group = [u for u in new_group if u not in users_to_remove] + # Remove users_to_remove from new_group_members + # Kinda like a new_group_members -= users_to_remove + new_group_members = [u for u in new_group_members if u not in users_to_remove] - new_group_dns = [ - "uid=" + user + ",ou=users,dc=yunohost,dc=org" for user in new_group - ] + # If something changed, we add this to the stuff to commit later in the code + if set(new_group_members) != set(current_group_members): + new_group_members_dns = [ + "uid=" + user + ",ou=users,dc=yunohost,dc=org" for user in new_group_members + ] + new_attr_dict["member"] = set(new_group_members_dns) + new_attr_dict["memberUid"] = set(new_group_members) - if set(new_group) != set(current_group): + # Check the whole alias situation + if add_mailalias: + + from yunohost.domain import domain_list + domains = domain_list()["domains"] + + if not isinstance(add_mailalias, list): + add_mailalias = [add_mailalias] + for mail in add_mailalias: + if mail.split("@")[0] in ADMIN_ALIASES and groupname != "admins": + raise YunohostValidationError("mail_unavailable") + if mail in current_group_mail: + continue + try: + ldap.validate_uniqueness({"mail": mail}) + except Exception as e: + raise YunohostError("group_update_failed", group=groupname, error=e) + if mail[mail.find("@") + 1 :] not in domains: + raise YunohostError( + "mail_domain_unknown", domain=mail[mail.find("@") + 1 :] + ) + new_group_mail.append(mail) + + if remove_mailalias: + from yunohost.domain import _get_maindomain + if not isinstance(remove_mailalias, list): + remove_mailalias = [remove_mailalias] + for mail in remove_mailalias: + if "@" in mail and mail.split("@")[0] in ADMIN_ALIASES and groupname == "admins" and mail.split("@")[1] == _get_maindomain(): + raise YunohostValidationError(f"The alias {mail} can not be removed from the 'admins' group", raw_msg=True) + if mail in new_group_mail: + new_group_mail.remove(mail) + else: + raise YunohostValidationError("mail_alias_remove_failed", mail=mail) + + if set(new_group_mail) != set(current_group_mail): + + logger.info(m18n.n("group_update_aliases", group=groupname)) + new_attr_dict["mail"] = set(new_group_mail) + + if new_attr_dict["mail"] and "mailAccount" not in group["objectClass"]: + new_attr_dict["objectClass"] = group["objectClass"] + ["mailAccount"] + elif not new_attr_dict["mail"] and "mailAccount" in group["objectClass"]: + new_attr_dict["objectClass"] = [c for c in group["objectClass"] if c != "mailAccount"] + + if new_attr_dict: if not from_import: operation_logger.start() - ldap = _get_ldap_interface() try: ldap.update( f"cn={groupname},ou=groups", - {"member": set(new_group_dns), "memberUid": set(new_group)}, + new_attr_dict ) except Exception as e: raise YunohostError("group_update_failed", group=groupname, error=e) @@ -1197,7 +1251,10 @@ def user_group_update( if not from_import: if groupname != "all_users": - logger.success(m18n.n("group_updated", group=groupname)) + if not new_attr_dict: + logger.info(m18n.n("group_no_change", group=groupname)) + else: + logger.success(m18n.n("group_updated", group=groupname)) else: logger.debug(m18n.n("group_updated", group=groupname)) @@ -1221,7 +1278,7 @@ def user_group_info(groupname): result = ldap.search( "ou=groups", "cn=" + groupname, - ["cn", "member", "permission"], + ["cn", "member", "permission", "mail"], ) if not result: @@ -1236,6 +1293,7 @@ def user_group_info(groupname): "permissions": [ _ldap_path_extract(p, "cn") for p in infos.get("permission", []) ], + "mail-aliases": [m for m in infos.get("mail", [])] } @@ -1265,6 +1323,13 @@ def user_group_remove(groupname, usernames, force=False, sync_perm=True): ) +def user_group_add_mailalias(groupname, aliases): + return user_group_update(groupname, add_mailalias=aliases, sync_perm=False) + + +def user_group_remove_mailalias(groupname, aliases): + return user_group_update(groupname, remove_mailalias=aliases, sync_perm=False) + # # Permission subcategory # @@ -1364,3 +1429,14 @@ def _hash_user_password(password): salt = "$6$" + salt + "$" return "{CRYPT}" + crypt.crypt(str(password), salt) + + +def _update_admins_group_aliases(old_main_domain, new_main_domain): + + current_admin_aliases = user_group_info("admins")["mail-aliases"] + + aliases_to_remove = [a for a in current_admin_aliases \ + if "@" in a and a.split("@")[1] == old_main_domain and a.split("@")[0] in ADMIN_ALIASES] + aliases_to_add = [f"{a}@{new_main_domain}" for a in ADMIN_ALIASES] + + user_group_update("admins", add_mailalias=aliases_to_add, remove_mailalias=aliases_to_remove)