changeset 6326:2623515d8507

mod_protect_last_admin: New module to ensure the last admin account on a server cannot be demoted or removed
author Matthew Wild <mwild1@gmail.com>
date Thu, 25 Sep 2025 18:21:27 +0100
parents ba2c344e1bdd
children 4f6fb56572f7
files mod_protect_last_admin/README.md mod_protect_last_admin/mod_protect_last_admin.lua
diffstat 2 files changed, 96 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_protect_last_admin/README.md	Thu Sep 25 18:21:27 2025 +0100
@@ -0,0 +1,46 @@
+---
+labels:
+- 'Stage-Beta'
+summary: Prevent the last admin account from being deleted or demoted
+...
+
+Introduction
+============
+
+This module ensures there is always one administrator (or one account with the
+specified roles, which can be configured). It prevents a situation where the
+only admin on a server deletes their account or switches to another role,
+leaving the server in a state where it has no administrator and no
+administrator can be created with manual intervention (e.g. using prosodyctl
+to set someone as an admin).
+
+Configuration
+=============
+
+Simply load the module as usual:
+
+``` {.lua}
+modules_enabled = {
+  ...
+    "protect_last_admin";
+  ...
+}
+```
+
+By default the module ensures there is at least one account with the
+`prosody:operator` or `prosody:admin` role. You can specify the list of roles
+using the `protect_last_admin_roles` setting in the configuration:
+
+``` {.lua}
+-- Ensure there is always one prosody:operator
+protect_last_admin_roles = { "prosody:operator" }
+```
+
+Compatibility
+=============
+
+  ----- -------
+  trunk  Works (requires commit [01f95f3de6fc](https://hg.prosody.im/trunk/rev/01f95f3de6fc) from 2025-09-25)
+  ----- -------
+  13.0   Not compatible
+  ----  -------
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_protect_last_admin/mod_protect_last_admin.lua	Thu Sep 25 18:21:27 2025 +0100
@@ -0,0 +1,50 @@
+module:set_global();
+
+local um = require "prosody.core.usermanager";
+
+local set = require "prosody.util.set";
+
+local protected_roles = module:get_option_set("protect_last_admin_roles", { "prosody:admin", "prosody:operator" });
+
+local function other_protected_users(username, host)
+	local protected_users = set.new();
+	for protected_role in protected_roles do
+		local users = um.get_users_with_role(protected_role, host);
+		module:log("debug", "Role %s: %s", protected_role, require "util.serialization".serialize(users, "debug"));
+		protected_users:add_list(um.get_users_with_role(protected_role, host));
+	end
+	protected_users:remove(username);
+	return not protected_users:empty();
+end
+
+function check_last_admin(event)
+	local username, host = event.username, event.host;
+
+	local current_role = um.get_user_role(username, host);
+	if not current_role then
+		module:log("warn", "Couldn't detect role for %s@%s", username, host);
+		return;
+	end
+
+	module:log("debug", "Checking whether to allow this event...");
+
+	if event.role_name and protected_roles:contains(event.role_name) then
+		module:log("debug", "Permitting role change to %s", event.role_name);
+		return; -- Switching to another protected role is fine
+	end
+
+	if not protected_roles:contains(current_role.name) then
+		module:log("debug", "Permitting event, role %s is not protected", current_role.name);
+		return; -- This is not a protected user
+	end
+
+	if not other_protected_users(username, host) then
+		event.reason = "Cannot remove the only administrator";
+		return false;
+	end
+	module:log("debug", "Permitting event, no reason not to!");
+end
+
+module:hook("pre-delete-user", check_last_admin);
+module:hook("pre-disable-user", check_last_admin);
+module:hook("pre-user-role-change", check_last_admin);