mod_authz_internal, and more: New iteration of role API
authorMatthew Wild <mwild1@gmail.com>
Wed, 17 Aug 2022 16:38:53 +0100
changeset 12666 07424992d7fc
parent 12665 1c391c17a907
child 12667 cf88f6b03942
mod_authz_internal, and more: New iteration of role API These changes to the API (hopefully the last) introduce a cleaner separation between the user's primary (default) role, and their secondary (optional) roles. To keep the code sane and reduce complexity, a data migration is needed for people using stored roles in 0.12. This can be performed with prosodyctl mod_authz_internal migrate <host>
core/moduleapi.lua
core/sessionmanager.lua
core/usermanager.lua
plugins/mod_authz_internal.lua
plugins/mod_c2s.lua
plugins/mod_tokenauth.lua
--- a/core/moduleapi.lua	Fri Aug 12 22:09:09 2022 +0200
+++ b/core/moduleapi.lua	Wed Aug 17 16:38:53 2022 +0100
@@ -538,6 +538,7 @@
 end
 
 function api:open_store(name, store_type)
+	if self.host == "*" then return nil, "global-storage-not-supported"; end
 	return require"core.storagemanager".open(self.host, name or self.name, store_type);
 end
 
@@ -629,7 +630,7 @@
 		local role;
 		local node, host = jid_split(context);
 		if host == self.host then
-			role = hosts[host].authz.get_user_default_role(node);
+			role = hosts[host].authz.get_user_role(node);
 		else
 			role = hosts[self.host].authz.get_jid_role(context);
 		end
--- a/core/sessionmanager.lua	Fri Aug 12 22:09:09 2022 +0200
+++ b/core/sessionmanager.lua	Wed Aug 17 16:38:53 2022 +0100
@@ -135,7 +135,7 @@
 	if role_name then
 		role = hosts[session.host].authz.get_role_by_name(role_name);
 	else
-		role = hosts[session.host].authz.get_user_default_role(username);
+		role = hosts[session.host].authz.get_user_role(username);
 	end
 	if role then
 		sessionlib.set_role(session, role);
--- a/core/usermanager.lua	Fri Aug 12 22:09:09 2022 +0200
+++ b/core/usermanager.lua	Wed Aug 17 16:38:53 2022 +0100
@@ -37,13 +37,17 @@
 local fallback_authz_provider = {
 	get_user_roles = function (user) end; --luacheck: ignore 212/user
 	get_jids_with_role = function (role) end; --luacheck: ignore 212
-	set_user_roles = function (user, roles) end; -- luacheck: ignore 212
-	set_jid_roles = function (jid, roles) end; -- luacheck: ignore 212
+
+	get_user_role = function (user) end; -- luacheck: ignore 212
+	set_user_role = function (user, roles) end; -- luacheck: ignore 212
 
-	get_user_default_role = function (user) end; -- luacheck: ignore 212
+	add_user_secondary_role = function (user, host, role_name) end; --luacheck: ignore 212
+	remove_user_secondary_role = function (user, host, role_name) end; --luacheck: ignore 212
+
+	get_jid_role = function (jid) end; -- luacheck: ignore 212
+	set_jid_role = function (jid, role) end; -- luacheck: ignore 212
+
 	get_users_with_role = function (role_name) end; -- luacheck: ignore 212
-	get_jid_role = function (jid) end; -- luacheck: ignore 212
-	set_jid_role = function (jid) end; -- luacheck: ignore 212
 	add_default_permission = function (role_name, action, policy) end; -- luacheck: ignore 212
 	get_role_by_name = function (role_name) end; -- luacheck: ignore 212
 };
@@ -140,39 +144,63 @@
 	return hosts[host].users;
 end
 
--- Returns a map of { [role_name] = role, ... } that a user is allowed to assume
-local function get_user_roles(user, host)
+local function get_user_role(user, host)
 	if host and not hosts[host] then return false; end
 	if type(user) ~= "string" then return false; end
 
-	return hosts[host].authz.get_user_roles(user);
+	return hosts[host].authz.get_user_role(user);
 end
 
-local function get_user_default_role(user, host)
+local function set_user_role(user, host, role_name)
 	if host and not hosts[host] then return false; end
 	if type(user) ~= "string" then return false; end
 
-	return hosts[host].authz.get_user_default_role(user);
+	local role, err = hosts[host].authz.set_user_role(user, role_name);
+	if role then
+		prosody.events.fire_event("user-role-changed", {
+			username = user, host = host, role = role;
+		});
+	end
+	return role, err;
 end
 
--- Accepts a set of role names which the user is allowed to assume
-local function set_user_roles(user, host, roles)
+local function add_user_secondary_role(user, host, role_name)
 	if host and not hosts[host] then return false; end
 	if type(user) ~= "string" then return false; end
 
-	local ok, err = hosts[host].authz.set_user_roles(user, roles);
+	local role, err = hosts[host].authz.add_user_secondary_role(user, role_name);
+	if role then
+		prosody.events.fire_event("user-role-added", {
+			username = user, host = host, role = role;
+		});
+	end
+	return role, err;
+end
+
+local function remove_user_secondary_role(user, host, role_name)
+	if host and not hosts[host] then return false; end
+	if type(user) ~= "string" then return false; end
+
+	local ok, err = hosts[host].authz.remove_user_secondary_role(user, role_name);
 	if ok then
-		prosody.events.fire_event("user-roles-changed", {
-			username = user, host = host
+		prosody.events.fire_event("user-role-removed", {
+			username = user, host = host, role_name = role_name;
 		});
 	end
 	return ok, err;
 end
 
+local function get_user_secondary_roles(user, host)
+	if host and not hosts[host] then return false; end
+	if type(user) ~= "string" then return false; end
+
+	return hosts[host].authz.get_user_secondary_roles(user);
+end
+
 local function get_jid_role(jid, host)
 	local jid_node, jid_host = jid_split(jid);
 	if host == jid_host and jid_node then
-		return hosts[host].authz.get_user_default_role(jid_node);
+		return hosts[host].authz.get_user_role(jid_node);
 	end
 	return hosts[host].authz.get_jid_role(jid);
 end
@@ -230,9 +258,11 @@
 	users = users;
 	get_sasl_handler = get_sasl_handler;
 	get_provider = get_provider;
-	get_user_default_role = get_user_default_role;
-	get_user_roles = get_user_roles;
-	set_user_roles = set_user_roles;
+	get_user_role = get_user_role;
+	set_user_role = set_user_role;
+	add_user_secondary_role = add_user_secondary_role;
+	remove_user_secondary_role = remove_user_secondary_role;
+	get_user_secondary_roles = get_user_secondary_roles;
 	get_users_with_role = get_users_with_role;
 	get_jid_role = get_jid_role;
 	set_jid_role = set_jid_role;
--- a/plugins/mod_authz_internal.lua	Fri Aug 12 22:09:09 2022 +0200
+++ b/plugins/mod_authz_internal.lua	Wed Aug 17 16:38:53 2022 +0100
@@ -8,8 +8,9 @@
 local config_global_admin_jids = module:context("*"):get_option_set("admins", {}) / normalize;
 local config_admin_jids = module:get_option_inherited_set("admins", {}) / normalize;
 local host = module.host;
-local role_store = module:open_store("roles");
-local role_map_store = module:open_store("roles", "map");
+
+local role_store = module:open_store("account_roles");
+local role_map_store = module:open_store("account_roles", "map");
 
 local role_registry = {};
 
@@ -98,52 +99,96 @@
 
 -- Public API
 
-local config_operator_role_set = {
-	["prosody:operator"] = role_registry["prosody:operator"];
-};
-local config_admin_role_set = {
-	["prosody:admin"] = role_registry["prosody:admin"];
-};
-local default_role_set = {
-	["prosody:user"] = role_registry["prosody:user"];
-};
-
-function get_user_roles(user)
+-- Get the primary role of a user
+function get_user_role(user)
 	local bare_jid = user.."@"..host;
+
+	-- Check config first
 	if config_global_admin_jids:contains(bare_jid) then
-		return config_operator_role_set;
+		return role_registry["prosody:operator"];
 	elseif config_admin_jids:contains(bare_jid) then
-		return config_admin_role_set;
+		return role_registry["prosody:admin"];
 	end
-	local role_names = role_store:get(user);
-	if not role_names then return default_role_set; end
-	local user_roles = {};
-	for role_name in pairs(role_names) do
-		user_roles[role_name] = role_registry[role_name];
+
+	-- Check storage
+	local stored_roles, err = role_store:get(user);
+	if not stored_roles then
+		if err then
+			-- Unable to fetch role, fail
+			return nil, err;
+		end
+		-- No role set, use default role
+		return role_registry["prosody:user"];
 	end
-	return user_roles;
+	if stored_roles._default == nil then
+		-- No primary role explicitly set, return default
+		return role_registry["prosody:user"];
+	end
+	local primary_stored_role = role_registry[stored_roles._default];
+	if not primary_stored_role then
+		return nil, "unknown-role";
+	end
+	return primary_stored_role;
 end
 
-function set_user_roles(user, user_roles)
-	role_store:set(user, user_roles)
-	return true;
+-- Set the primary role of a user
+function set_user_role(user, role_name)
+	local role = role_registry[role_name];
+	if not role then
+		return error("Cannot assign default user an unknown role: "..tostring(role_name));
+	end
+	local keys_update = {
+		_default = role_name;
+		-- Primary role cannot be secondary role
+		[role_name] = role_map_store.remove;
+	};
+	if role_name == "prosody:user" then
+		-- Don't store default
+		keys_update._default = role_map_store.remove;
+	end
+	local ok, err = role_map_store:set_keys(user, keys_update);
+	if not ok then
+		return nil, err;
+	end
+	return role;
+end
+
+function add_user_secondary_role(user, role_name)
+	if not role_registry[role_name] then
+		return error("Cannot assign default user an unknown role: "..tostring(role_name));
+	end
+	role_map_store:set(user, role_name, true);
 end
 
-function get_user_default_role(user)
-	local user_roles = get_user_roles(user);
-	if not user_roles then return nil; end
-	local default_role;
-	for role_name, role_info in pairs(user_roles) do --luacheck: ignore 213/role_name
-		if role_info.default ~= false and (not default_role or role_info.priority > default_role.priority) then
-			default_role = role_info;
-		end
-	end
-	if not default_role then return nil; end
-	return default_role;
+function remove_user_secondary_role(user, role_name)
+	role_map_store:set(user, role_name, nil);
 end
 
+function get_user_secondary_roles(user)
+	local stored_roles, err = role_store:get(user);
+	if not stored_roles then
+		if err then
+			-- Unable to fetch role, fail
+			return nil, err;
+		end
+		-- No role set
+		return {};
+	end
+	stored_roles._default = nil;
+	for role_name in pairs(stored_roles) do
+		stored_roles[role_name] = role_registry[role_name];
+	end
+	return stored_roles;
+end
+
+-- This function is *expensive*
 function get_users_with_role(role_name)
-	local storage_role_users = it.to_array(it.keys(role_map_store:get_all(role_name) or {}));
+	local function role_filter(username, default_role) --luacheck: ignore 212/username
+		return default_role == role_name;
+	end
+	local primary_role_users = set.new(it.to_array(it.filter(role_filter, pairs(role_map_store:get_all("_default") or {}))));
+	local secondary_role_users = set.new(it.to_array(it.keys(role_map_store:get_all(role_name) or {})));
+
 	local config_set;
 	if role_name == "prosody:admin" then
 		config_set = config_admin_jids;
@@ -157,9 +202,9 @@
 				return j_node;
 			end
 		end;
-		return it.to_array(config_admin_users + set.new(storage_role_users));
+		return it.to_array(config_admin_users + primary_role_users + secondary_role_users);
 	end
-	return storage_role_users;
+	return it.to_array(primary_role_users + secondary_role_users);
 end
 
 function get_jid_role(jid)
@@ -203,3 +248,52 @@
 function get_role_by_name(role_name)
 	return assert(role_registry[role_name], role_name);
 end
+
+-- COMPAT: Migrate from 0.12 role storage
+local function do_migration(migrate_host)
+	local old_role_store = assert(module:context(migrate_host):open_store("roles"));
+	local new_role_store = assert(module:context(migrate_host):open_store("account_roles"));
+
+	local migrated, failed, skipped = 0, 0, 0;
+	-- Iterate all users
+	for username in assert(old_role_store:users()) do
+		local old_roles = it.to_array(it.filter(function (k) return k:sub(1,1) ~= "_"; end, it.keys(old_role_store:get(username))));
+		if #old_roles == 1 then
+			local ok, err = new_role_store:set(username, {
+				_default = old_roles[1];
+			});
+			if ok then
+				migrated = migrated + 1;
+			else
+				failed = failed + 1;
+				print("EE: Failed to store new role info for '"..username.."': "..err);
+			end
+		else
+			print("WW: User '"..username.."' has multiple roles and cannot be automatically migrated");
+			skipped = skipped + 1;
+		end
+	end
+	return migrated, failed, skipped;
+end
+
+function module.command(arg)
+	if arg[1] == "migrate" then
+		table.remove(arg, 1);
+		local migrate_host = arg[1];
+		if not migrate_host or not prosody.hosts[migrate_host] then
+			print("EE: Please supply a valid host to migrate to the new role storage");
+			return 1;
+		end
+
+		-- Initialize storage layer
+		require "core.storagemanager".initialize_host(migrate_host);
+
+		print("II: Migrating roles...");
+		local migrated, failed, skipped = do_migration(migrate_host);
+		print(("II: %d migrated, %d failed, %d skipped"):format(migrated, failed, skipped));
+		return (failed + skipped == 0) and 0 or 1;
+	else
+		print("EE: Unknown command: "..(arg[1] or "<none given>"));
+		print("    Hint: try 'migrate'?");
+	end
+end
--- a/plugins/mod_c2s.lua	Fri Aug 12 22:09:09 2022 +0200
+++ b/plugins/mod_c2s.lua	Wed Aug 17 16:38:53 2022 +0100
@@ -259,7 +259,7 @@
 end
 
 module:hook_global("user-password-changed", disconnect_user_sessions({ condition = "reset", text = "Password changed" }, true), 200);
-module:hook_global("user-roles-changed", disconnect_user_sessions({ condition = "reset", text = "Roles changed" }), 200);
+module:hook_global("user-role-changed", disconnect_user_sessions({ condition = "reset", text = "Role changed" }), 200);
 module:hook_global("user-deleted", disconnect_user_sessions({ condition = "not-authorized", text = "Account deleted" }), 200);
 
 function runner_callbacks:ready()
--- a/plugins/mod_tokenauth.lua	Fri Aug 12 22:09:09 2022 +0200
+++ b/plugins/mod_tokenauth.lua	Wed Aug 17 16:38:53 2022 +0100
@@ -10,7 +10,7 @@
 	if role then
 		return prosody.hosts[host].authz.get_role_by_name(role);
 	end
-	return usermanager.get_user_default_role(username, host);
+	return usermanager.get_user_role(username, host);
 end
 
 function create_jid_token(actor_jid, token_jid, token_role, token_ttl)