Changeset

12674:72f431b4dc2c

Merge role-auth->trunk
author Matthew Wild <mwild1@gmail.com>
date Mon, 22 Aug 2022 13:53:35 +0100
parents 12639:6d9ee0a3eb4b (current diff) 12673:2d330edf8bf2 (diff)
children 12675:db8c795ca81a
files
diffstat 28 files changed, 841 insertions(+), 263 deletions(-) [+]
line wrap: on
line diff
--- a/.luacheckrc	Fri Aug 19 14:24:31 2022 +0200
+++ b/.luacheckrc	Mon Aug 22 13:53:35 2022 +0100
@@ -62,6 +62,8 @@
 		"module.broadcast",
 		"module.context",
 		"module.depends",
+		"module.default_permission",
+		"module.default_permissions",
 		"module.fire_event",
 		"module.get_directory",
 		"module.get_host",
@@ -86,6 +88,7 @@
 		"module.load_resource",
 		"module.log",
 		"module.log_status",
+		"module.may",
 		"module.measure",
 		"module.metric",
 		"module.open_store",
--- a/CHANGES	Fri Aug 19 14:24:31 2022 +0200
+++ b/CHANGES	Mon Aug 22 13:53:35 2022 +0100
@@ -18,6 +18,7 @@
 
 - Advertise supported SASL Channel-Binding types (XEP-0440)
 - Implement RFC 9266 'tls-exporter' channel binding with TLS 1.3
+- New role and permissions framework and API
 
 ## Changes
 
--- a/core/features.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/core/features.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -4,5 +4,7 @@
 	available = set.new{
 		-- mod_bookmarks bundled
 		"mod_bookmarks";
+		-- Roles, module.may and per-session authz
+		"permissions";
 	};
 };
--- a/core/moduleapi.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/core/moduleapi.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -19,6 +19,7 @@
 local time_now = require "util.time".now;
 local format = require "util.format".format;
 local jid_node = require "util.jid".node;
+local jid_split = require "util.jid".split;
 local jid_resource = require "util.jid".resource;
 
 local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
@@ -537,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
 
@@ -601,4 +603,73 @@
 	return self.status_type, self.status_message, self.status_time;
 end
 
+function api:default_permission(role_name, permission)
+	permission = permission:gsub("^:", self.name..":");
+	if self.host == "*" then
+		for _, host in pairs(hosts) do
+			if host.authz then
+				host.authz.add_default_permission(role_name, permission);
+			end
+		end
+		return
+	end
+	hosts[self.host].authz.add_default_permission(role_name, permission);
+end
+
+function api:default_permissions(role_name, permissions)
+	for _, permission in ipairs(permissions) do
+		self:default_permission(role_name, permission);
+	end
+end
+
+function api:may(action, context)
+	if action:byte(1) == 58 then -- action begins with ':'
+		action = self.name..action; -- prepend module name
+	end
+	if type(context) == "string" then -- check JID permissions
+		local role;
+		local node, host = jid_split(context);
+		if host == self.host then
+			role = hosts[host].authz.get_user_role(node);
+		else
+			role = hosts[self.host].authz.get_jid_role(context);
+		end
+		if not role then
+			self:log("debug", "Access denied: JID <%s> may not %s (no role found)", context, action);
+			return false;
+		end
+		local permit = role:may(action);
+		if not permit then
+			self:log("debug", "Access denied: JID <%s> may not %s (not permitted by role %s)", context, action, role.name);
+		end
+		return permit;
+	end
+
+	local session = context.origin or context.session;
+	if type(session) ~= "table" then
+		error("Unable to identify actor session from context");
+	end
+	if session.type == "s2sin" or (session.type == "c2s" and session.host ~= self.host) then
+		local actor_jid = context.stanza.attr.from;
+		local role = hosts[self.host].authz.get_jid_role(actor_jid);
+		if not role then
+			self:log("debug", "Access denied: JID <%s> may not %s (no role found)", actor_jid, action);
+			return false;
+		end
+		local permit = role:may(action, context);
+		if not permit then
+			self:log("debug", "Access denied: JID <%s> may not %s (not permitted by role %s)", actor_jid, action, role.name);
+		end
+		return permit;
+	elseif session.role then
+		local permit = session.role:may(action, context);
+		if not permit then
+			self:log("debug", "Access denied: session %s (%s) may not %s (not permitted by role %s)",
+				session.id, session.full_jid, action, session.role.name
+			);
+		end
+		return permit;
+	end
+end
+
 return api;
--- a/core/sessionmanager.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/core/sessionmanager.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -123,15 +123,24 @@
 	retire_session(session);
 end
 
-local function make_authenticated(session, username, scope)
+local function make_authenticated(session, username, role_name)
 	username = nodeprep(username);
 	if not username or #username == 0 then return nil, "Invalid username"; end
 	session.username = username;
 	if session.type == "c2s_unauthed" then
 		session.type = "c2s_unbound";
 	end
-	session.auth_scope = scope;
-	session.log("info", "Authenticated as %s@%s", username, session.host or "(unknown)");
+
+	local role;
+	if role_name then
+		role = hosts[session.host].authz.get_role_by_name(role_name);
+	else
+		role = hosts[session.host].authz.get_user_role(username);
+	end
+	if role then
+		sessionlib.set_role(session, role);
+	end
+	session.log("info", "Authenticated as %s@%s [%s]", username, session.host or "(unknown)", role and role.name or "no role");
 	return true;
 end
 
--- a/core/usermanager.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/core/usermanager.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -9,14 +9,10 @@
 local modulemanager = require "core.modulemanager";
 local log = require "util.logger".init("usermanager");
 local type = type;
-local it = require "util.iterators";
-local jid_bare = require "util.jid".bare;
 local jid_split = require "util.jid".split;
-local jid_prep = require "util.jid".prep;
 local config = require "core.configmanager";
 local sasl_new = require "util.sasl".new;
 local storagemanager = require "core.storagemanager";
-local set = require "util.set";
 
 local prosody = _G.prosody;
 local hosts = prosody.hosts;
@@ -25,6 +21,8 @@
 
 local default_provider = "internal_hashed";
 
+local debug = debug;
+
 local _ENV = nil;
 -- luacheck: std none
 
@@ -36,26 +34,25 @@
 	});
 end
 
-local global_admins_config = config.get("*", "admins");
-if type(global_admins_config) ~= "table" then
-	global_admins_config = nil; -- TODO: factor out moduleapi magic config handling and use it here
-end
-local global_admins = set.new(global_admins_config) / jid_prep;
+local fallback_authz_provider = {
+	-- luacheck: ignore 212
+	get_jids_with_role = function (role) end;
+
+	get_user_role = function (user) end;
+	set_user_role = function (user, role_name) end;
 
-local admin_role = { ["prosody:admin"] = true };
-local global_authz_provider = {
-	get_user_roles = function (user) end; --luacheck: ignore 212/user
-	get_jid_roles = function (jid)
-		if global_admins:contains(jid) then
-			return admin_role;
-		end
-	end;
-	get_jids_with_role = function (role)
-		if role ~= "prosody:admin" then return {}; end
-		return it.to_array(global_admins);
-	end;
-	set_user_roles = function (user, roles) end; -- luacheck: ignore 212
-	set_jid_roles = function (jid, roles) end; -- luacheck: ignore 212
+	get_user_secondary_roles = function (user) end;
+	add_user_secondary_role = function (user, host, role_name) end;
+	remove_user_secondary_role = function (user, host, role_name) end;
+
+	user_can_assume_role = function(user, role_name) end;
+
+	get_jid_role = function (jid) end;
+	set_jid_role = function (jid, role) end;
+
+	get_users_with_role = function (role_name) end;
+	add_default_permission = function (role_name, action, policy) end;
+	get_role_by_name = function (role_name) end;
 };
 
 local provider_mt = { __index = new_null_provider() };
@@ -66,7 +63,7 @@
 	local authz_provider_name = config.get(host, "authorization") or "internal";
 
 	local authz_mod = modulemanager.load(host, "authz_"..authz_provider_name);
-	host_session.authz = authz_mod or global_authz_provider;
+	host_session.authz = authz_mod or fallback_authz_provider;
 
 	if host_session.type ~= "local" then return; end
 
@@ -116,6 +113,12 @@
 	return ok, err;
 end
 
+local function get_account_info(username, host)
+	local method = hosts[host].users.get_account_info;
+	if not method then return nil, "method-not-supported"; end
+	return method(username);
+end
+
 local function user_exists(username, host)
 	if hosts[host].sessions[username] then return true; end
 	return hosts[host].users.user_exists(username);
@@ -144,70 +147,112 @@
 	return hosts[host].users;
 end
 
-local function get_roles(jid, host)
+local function get_user_role(user, host)
 	if host and not hosts[host] then return false; end
-	if type(jid) ~= "string" then return false; end
+	if type(user) ~= "string" then return false; end
 
-	jid = jid_bare(jid);
-	host = host or "*";
+	return hosts[host].authz.get_user_role(user);
+end
 
-	local actor_user, actor_host = jid_split(jid);
-	local roles;
+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
 
-	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
+	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
+
+local function user_can_assume_role(user, host, role_name)
+	if host and not hosts[host] then return false; end
+	if type(user) ~= "string" then return false; end
 
-	if actor_user and actor_host == host then -- Local user
-		roles = authz_provider.get_user_roles(actor_user);
-	else -- Remote user/JID
-		roles = authz_provider.get_jid_roles(jid);
+	return hosts[host].authz.user_can_assume_role(user, role_name);
+end
+
+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 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 roles;
+	return role, err;
 end
 
-local function set_roles(jid, host, roles)
+local function remove_user_secondary_role(user, host, role_name)
 	if host and not hosts[host] then return false; end
-	if type(jid) ~= "string" then return false; end
-
-	jid = jid_bare(jid);
-	host = host or "*";
-
-	local actor_user, actor_host = jid_split(jid);
+	if type(user) ~= "string" then return false; end
 
-	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
-	if actor_user and actor_host == host then -- Local user
-		local ok, err = authz_provider.set_user_roles(actor_user, roles);
-		if ok then
-			prosody.events.fire_event("user-roles-changed", {
-				username = actor_user, host = actor_host
-			});
-		end
-		return ok, err;
-	else -- Remote entity
-		return authz_provider.set_jid_roles(jid, roles)
+	local ok, err = hosts[host].authz.remove_user_secondary_role(user, role_name);
+	if ok then
+		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_role(jid_node);
+	end
+	return hosts[host].authz.get_jid_role(jid);
+end
+
+local function set_jid_role(jid, host, role_name)
+	local _, jid_host = jid_split(jid);
+	if host == jid_host then
+		return nil, "unexpected-local-jid";
+	end
+	return hosts[host].authz.set_jid_role(jid, role_name)
+end
+
+local strict_deprecate_is_admin;
+local legacy_admin_roles = { ["prosody:admin"] = true, ["prosody:operator"] = true };
 local function is_admin(jid, host)
-	local roles = get_roles(jid, host);
-	return roles and roles["prosody:admin"];
+	if strict_deprecate_is_admin == nil then
+		strict_deprecate_is_admin = (config.get("*", "strict_deprecate_is_admin") == true);
+	end
+	if strict_deprecate_is_admin then
+		log("error", "Attempt to use deprecated is_admin() API: %s", debug.traceback());
+		return false;
+	end
+	log("warn", "Usage of legacy is_admin() API, which will be disabled in a future build: %s", debug.traceback());
+	return legacy_admin_roles[get_jid_role(jid, host)] or false;
 end
 
 local function get_users_with_role(role, host)
 	if not hosts[host] then return false; end
 	if type(role) ~= "string" then return false; end
-
 	return hosts[host].authz.get_users_with_role(role);
 end
 
 local function get_jids_with_role(role, host)
 	if host and not hosts[host] then return false; end
 	if type(role) ~= "string" then return false; end
-
-	host = host or "*";
+	return hosts[host].authz.get_jids_with_role(role);
+end
 
-	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
-	return authz_provider.get_jids_with_role(role);
+local function get_role_by_name(role_name, host)
+	if host and not hosts[host] then return false; end
+	if type(role_name) ~= "string" then return false; end
+	return hosts[host].authz.get_role_by_name(role_name);
 end
 
 return {
@@ -216,15 +261,25 @@
 	test_password = test_password;
 	get_password = get_password;
 	set_password = set_password;
+	get_account_info = get_account_info;
 	user_exists = user_exists;
 	create_user = create_user;
 	delete_user = delete_user;
 	users = users;
 	get_sasl_handler = get_sasl_handler;
 	get_provider = get_provider;
-	get_roles = get_roles;
-	set_roles = set_roles;
+	get_user_role = get_user_role;
+	set_user_role = set_user_role;
+	user_can_assume_role = user_can_assume_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;
+	get_jids_with_role = get_jids_with_role;
+	get_role_by_name = get_role_by_name;
+
+	-- Deprecated
 	is_admin = is_admin;
-	get_users_with_role = get_users_with_role;
-	get_jids_with_role = get_jids_with_role;
 };
--- a/plugins/adhoc/adhoc.lib.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/adhoc/adhoc.lib.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -23,9 +23,15 @@
 function _M.new(name, node, handler, permission)
 	if not permission then
 		error "adhoc.new() expects a permission argument, none given"
+	elseif permission == "user" then
+		error "the permission mode 'user' has been renamed 'any', please update your code"
 	end
-	if permission == "user" then
-		error "the permission mode 'user' has been renamed 'any', please update your code"
+	if permission == "admin" then
+		module:default_permission("prosody:admin", "mod_adhoc:"..node);
+		permission = "check";
+	elseif permission == "global_admin" then
+		module:default_permission("prosody:operator", "mod_adhoc:"..node);
+		permission = "check";
 	end
 	return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = permission };
 end
--- a/plugins/adhoc/mod_adhoc.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/adhoc/mod_adhoc.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -7,7 +7,6 @@
 
 local it = require "util.iterators";
 local st = require "util.stanza";
-local is_admin = require "core.usermanager".is_admin;
 local jid_host = require "util.jid".host;
 local adhoc_handle_cmd = module:require "adhoc".handle_cmd;
 local xmlns_cmd = "http://jabber.org/protocol/commands";
@@ -15,18 +14,17 @@
 
 module:add_feature(xmlns_cmd);
 
+local function check_permissions(event, node, command)
+	return (command.permission == "check" and module:may("mod_adhoc:"..node, event))
+	    or (command.permission == "local_user" and jid_host(event.stanza.attr.from) == module.host)
+	    or (command.permission == "any");
+end
+
 module:hook("host-disco-info-node", function (event)
 	local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node;
 	if commands[node] then
-		local from = stanza.attr.from;
-		local privileged = is_admin(from, stanza.attr.to);
-		local global_admin = is_admin(from);
-		local hostname = jid_host(from);
 		local command = commands[node];
-		if (command.permission == "admin" and privileged)
-		    or (command.permission == "global_admin" and global_admin)
-		    or (command.permission == "local_user" and hostname == module.host)
-		    or (command.permission == "any") then
+		if check_permissions(event, node, command) then
 			reply:tag("identity", { name = command.name,
 			    category = "automation", type = "command-node" }):up();
 			reply:tag("feature", { var = xmlns_cmd }):up();
@@ -44,20 +42,13 @@
 end);
 
 module:hook("host-disco-items-node", function (event)
-	local stanza, reply, disco_node = event.stanza, event.reply, event.node;
+	local reply, disco_node = event.reply, event.node;
 	if disco_node ~= xmlns_cmd then
 		return;
 	end
 
-	local from = stanza.attr.from;
-	local admin = is_admin(from, stanza.attr.to);
-	local global_admin = is_admin(from);
-	local hostname = jid_host(from);
 	for node, command in it.sorted_pairs(commands) do
-		if (command.permission == "admin" and admin)
-		    or (command.permission == "global_admin" and global_admin)
-		    or (command.permission == "local_user" and hostname == module.host)
-		    or (command.permission == "any") then
+		if check_permissions(event, node, command) then
 			reply:tag("item", { name = command.name,
 			    node = node, jid = module:get_host() });
 			reply:up();
@@ -71,15 +62,9 @@
 	local node = stanza.tags[1].attr.node
 	local command = commands[node];
 	if command then
-		local from = stanza.attr.from;
-		local admin = is_admin(from, stanza.attr.to);
-		local global_admin = is_admin(from);
-		local hostname = jid_host(from);
-		if (command.permission == "admin" and not admin)
-		    or (command.permission == "global_admin" and not global_admin)
-		    or (command.permission == "local_user" and hostname ~= module.host) then
+		if not check_permissions(event, node, command) then
 			origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up()
-			    :add_child(command:cmdtag("canceled")
+				:add_child(command:cmdtag("canceled")
 				:tag("note", {type="error"}):text("You don't have permission to execute this command")));
 			return true
 		end
--- a/plugins/mod_admin_shell.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_admin_shell.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -268,23 +268,22 @@
 		print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
 		print [[host:list() - List the currently-activated hosts]]
 	elseif section == "user" then
-		print [[user:create(jid, password, roles) - Create the specified user account]]
+		print [[user:create(jid, password, role) - Create the specified user account]]
 		print [[user:password(jid, password) - Set the password for the specified user account]]
 		print [[user:roles(jid, host) - Show current roles for an user]]
-		print [[user:setroles(jid, host, roles) - Set roles for an user (see 'help roles')]]
+		print [[user:setrole(jid, host, role) - Set primary role of a user (see 'help roles')]]
+		print [[user:addrole(jid, host, role) - Add a secondary role to a user]]
+		print [[user:delrole(jid, host, role) - Remove a secondary role from a user]]
 		print [[user:delete(jid) - Permanently remove the specified user account]]
 		print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
 	elseif section == "roles" then
 		print [[Roles may grant access or restrict users from certain operations]]
 		print [[Built-in roles are:]]
-		print [[  prosody:admin - Administrator]]
-		print [[  (empty set) - Normal user]]
+		print [[  prosody:user     - Normal user (default)]]
+		print [[  prosody:admin    - Host administrator]]
+		print [[  prosody:operator - Server administrator]]
 		print [[]]
-		print [[The canonical role format looks like: { ["example:role"] = true }]]
-		print [[For convenience, the following formats are also accepted:]]
-		print [["admin" - short for "prosody:admin", the normal admin status (like the admins config option)]]
-		print [["example:role" - short for {["example:role"]=true}]]
-		print [[{"example:role"} - short for {["example:role"]=true}]]
+		print [[Roles can be assigned using the user management commands (see 'help user').]]
 	elseif section == "muc" then
 		-- TODO `muc:room():foo()` commands
 		print [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]]
@@ -943,6 +942,15 @@
 			end
 		end
 	};
+	role = {
+		title = "Role";
+		description = "Session role";
+		width = 20;
+		key = "role";
+		mapper = function(role)
+			return role.name;
+		end;
+	}
 };
 
 local function get_colspec(colspec, default)
@@ -963,7 +971,7 @@
 
 function def_env.c2s:show(match_jid, colspec)
 	local print = self.session.print;
-	local columns = get_colspec(colspec, { "id"; "jid"; "ipv"; "status"; "secure"; "smacks"; "csi" });
+	local columns = get_colspec(colspec, { "id"; "jid"; "role"; "ipv"; "status"; "secure"; "smacks"; "csi" });
 	local row = format_table(columns, self.session.width);
 
 	local function match(session)
@@ -1374,32 +1382,32 @@
 
 local um = require"core.usermanager";
 
-local function coerce_roles(roles)
-	if roles == "admin" then roles = "prosody:admin"; end
-	if type(roles) == "string" then roles = { [roles] = true }; end
-	if roles[1] then for i, role in ipairs(roles) do roles[role], roles[i] = true, nil; end end
-	return roles;
-end
-
 def_env.user = {};
-function def_env.user:create(jid, password, roles)
+function def_env.user:create(jid, password, role)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif um.user_exists(username, host) then
 		return nil, "User exists";
 	end
-	local ok, err = um.create_user(username, password, host);
-	if ok then
-		if ok and roles then
-			roles = coerce_roles(roles);
-			local roles_ok, rerr = um.set_roles(jid, host, roles);
-			if not roles_ok then return nil, "User created, but could not set roles: " .. tostring(rerr); end
-		end
-		return true, "User created";
-	else
+	local ok, err = um.create_user(username, nil, host);
+	if not ok then
 		return nil, "Could not create user: "..err;
 	end
+
+	if role then
+		local role_ok, rerr = um.set_user_role(jid, host, role);
+		if not role_ok then
+			return nil, "Could not set role: " .. tostring(rerr);
+		end
+	end
+
+	local ok, err = um.set_password(username, password, host, nil);
+	if not ok then
+		return nil, "Could not set password for user: "..err;
+	end
+
+	return true, "User created";
 end
 
 function def_env.user:delete(jid)
@@ -1432,41 +1440,63 @@
 	end
 end
 
-function def_env.user:roles(jid, host, new_roles)
-	if new_roles or type(host) == "table" then
-		return nil, "Use user:setroles(jid, host, roles) to change user roles";
-	end
+function def_env.user:role(jid, host)
 	local username, userhost = jid_split(jid);
 	if host == nil then host = userhost; end
-	if host ~= "*" and not prosody.hosts[host] then
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
 		return nil, "No such user";
 	end
-	local roles = um.get_roles(jid, host);
-	if not roles then return true, "No roles"; end
-	local count = 0;
-	local print = self.session.print;
-	for role in pairs(roles) do
+
+	local primary_role = um.get_user_role(username, host);
+	local secondary_roles = um.get_user_secondary_roles(username, host);
+
+	print(primary_role and primary_role.name or "<none>");
+
+	local count = primary_role and 1 or 0;
+	for role_name in pairs(secondary_roles or {}) do
 		count = count + 1;
-		print(role);
+		print(role_name.." (secondary)");
 	end
+
 	return true, count == 1 and "1 role" or count.." roles";
 end
-def_env.user.showroles = def_env.user.roles; -- COMPAT
+def_env.user.roles = def_env.user.role;
 
--- user:roles("someone@example.com", "example.com", {"prosody:admin"})
--- user:roles("someone@example.com", {"prosody:admin"})
-function def_env.user:setroles(jid, host, new_roles)
+-- user:setrole("someone@example.com", "example.com", "prosody:admin")
+-- user:setrole("someone@example.com", "prosody:admin")
+function def_env.user:setrole(jid, host, new_role)
 	local username, userhost = jid_split(jid);
-	if new_roles == nil then host, new_roles = userhost, host; end
-	if host ~= "*" and not prosody.hosts[host] then
+	if new_role == nil then host, new_role = userhost, host; end
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
 		return nil, "No such user";
 	end
-	if host == "*" then host = nil; end
-	return um.set_roles(jid, host, coerce_roles(new_roles));
+	return um.set_user_role(username, host, new_role);
+end
+
+function def_env.user:addrole(jid, host, new_role)
+	local username, userhost = jid_split(jid);
+	if new_role == nil then host, new_role = userhost, host; end
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
+		return nil, "No such user";
+	end
+	return um.add_user_secondary_role(username, host, new_role);
+end
+
+function def_env.user:delrole(jid, host, role_name)
+	local username, userhost = jid_split(jid);
+	if role_name == nil then host, role_name = userhost, host; end
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
+		return nil, "No such user";
+	end
+	return um.remove_user_secondary_role(username, host, role_name);
 end
 
 -- TODO switch to table view, include roles
--- a/plugins/mod_announce.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_announce.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -9,7 +9,6 @@
 local st, jid = require "util.stanza", require "util.jid";
 
 local hosts = prosody.hosts;
-local is_admin = require "core.usermanager".is_admin;
 
 function send_to_online(message, host)
 	local sessions;
@@ -34,6 +33,7 @@
 	return c;
 end
 
+module:default_permission("prosody:admin", ":send-announcement");
 
 -- Old <message>-based jabberd-style announcement sending
 function handle_announcement(event)
@@ -45,8 +45,8 @@
 		return; -- Not an announcement
 	end
 
-	if not is_admin(stanza.attr.from, host) then
-		-- Not an admin? Not allowed!
+	if not module:may(":send-announcement", event) then
+		-- Not allowed!
 		module:log("warn", "Non-admin '%s' tried to send server announcement", stanza.attr.from);
 		return;
 	end
--- a/plugins/mod_auth_insecure.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_auth_insecure.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -27,6 +27,7 @@
 		return nil, "Password fails SASLprep.";
 	end
 	if account then
+		account.updated = os.time();
 		account.password = password;
 		return datamanager.store(username, host, "accounts", account);
 	end
@@ -38,7 +39,8 @@
 end
 
 function provider.create_user(username, password)
-	return datamanager.store(username, host, "accounts", {password = password});
+	local now = os.time();
+	return datamanager.store(username, host, "accounts", { created = now; updated = now; password = password });
 end
 
 function provider.delete_user(username)
--- a/plugins/mod_auth_internal_hashed.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_auth_internal_hashed.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -86,11 +86,21 @@
 		account.server_key = server_key_hex
 
 		account.password = nil;
+		account.updated = os.time();
 		return accounts:set(username, account);
 	end
 	return nil, "Account not available.";
 end
 
+function provider.get_account_info(username)
+	local account = accounts:get(username);
+	if not account then return nil, "Account not available"; end
+	return {
+		created = account.created;
+		password_updated = account.updated;
+	};
+end
+
 function provider.user_exists(username)
 	local account = accounts:get(username);
 	if not account then
@@ -105,8 +115,9 @@
 end
 
 function provider.create_user(username, password)
+	local now = os.time();
 	if password == nil then
-		return accounts:set(username, {});
+		return accounts:set(username, { created = now; updated = now; disabled = true });
 	end
 	local salt = generate_uuid();
 	local valid, stored_key, server_key = get_auth_db(password, salt, default_iteration_count);
@@ -117,7 +128,8 @@
 	local server_key_hex = to_hex(server_key);
 	return accounts:set(username, {
 		stored_key = stored_key_hex, server_key = server_key_hex,
-		salt = salt, iteration_count = default_iteration_count
+		salt = salt, iteration_count = default_iteration_count,
+		created = now, updated = now;
 	});
 end
 
--- a/plugins/mod_auth_internal_plain.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_auth_internal_plain.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -48,11 +48,21 @@
 	local account = accounts:get(username);
 	if account then
 		account.password = password;
+		account.updated = os.time();
 		return accounts:set(username, account);
 	end
 	return nil, "Account not available.";
 end
 
+function provider.get_account_info(username)
+	local account = accounts:get(username);
+	if not account then return nil, "Account not available"; end
+	return {
+		created = account.created;
+		password_updated = account.updated;
+	};
+end
+
 function provider.user_exists(username)
 	local account = accounts:get(username);
 	if not account then
@@ -71,7 +81,11 @@
 	if not password then
 		return nil, "Password fails SASLprep.";
 	end
-	return accounts:set(username, {password = password});
+	local now = os.time();
+	return accounts:set(username, {
+		password = password;
+		created = now, updated = now;
+	});
 end
 
 function provider.delete_user(username)
--- a/plugins/mod_auth_ldap.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_auth_ldap.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -1,6 +1,5 @@
 -- mod_auth_ldap
 
-local jid_split = require "util.jid".split;
 local new_sasl = require "util.sasl".new;
 local lualdap = require "lualdap";
 
@@ -21,6 +20,13 @@
 	module:get_option_string("ldap_admins")); -- COMPAT with mistake in documentation
 local host = ldap_filter_escape(module:get_option_string("realm", module.host));
 
+if ldap_admins then
+	module:log("error", "The 'ldap_admin_filter' option has been deprecated, "..
+	           "and will be ignored. Equivalent functionality may be added in "..
+	           "the future if there is demand."
+	);
+end
+
 -- Initiate connection
 local ld = nil;
 module.unload = function() if ld then pcall(ld, ld.close); end end
@@ -133,22 +139,4 @@
 	module:log("error", "Unsupported ldap_mode %s", tostring(ldap_mode));
 end
 
-if ldap_admins then
-	function provider.is_admin(jid)
-		local username, user_host = jid_split(jid);
-		if user_host ~= module.host then
-			return false;
-		end
-		return ldap_do("search", 2, {
-			base = ldap_base;
-			scope = ldap_scope;
-			sizelimit = 1;
-			filter = ldap_admins:gsub("%$(%a+)", {
-				user = ldap_filter_escape(username);
-				host = host;
-			});
-		});
-	end
-end
-
 module:provides("auth", provider);
--- a/plugins/mod_authz_internal.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_authz_internal.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -1,59 +1,311 @@
 local array = require "util.array";
 local it = require "util.iterators";
 local set = require "util.set";
-local jid_split = require "util.jid".split;
+local jid_split, jid_bare = require "util.jid".split, require "util.jid".bare;
 local normalize = require "util.jid".prep;
+local roles = require "util.roles";
+
+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 = {};
 
-local admin_role = { ["prosody:admin"] = true };
+function register_role(role)
+	if role_registry[role.name] ~= nil then
+		return error("A role '"..role.name.."' is already registered");
+	end
+	if not roles.is_role(role) then
+		-- Convert table syntax to real role object
+		for i, inherited_role in ipairs(role.inherits or {}) do
+			if type(inherited_role) == "string" then
+				role.inherits[i] = assert(role_registry[inherited_role], "The named role '"..inherited_role.."' is not registered");
+			end
+		end
+		if not role.permissions then role.permissions = {}; end
+		for _, allow_permission in ipairs(role.allow or {}) do
+			role.permissions[allow_permission] = true;
+		end
+		for _, deny_permission in ipairs(role.deny or {}) do
+			role.permissions[deny_permission] = false;
+		end
+		role = roles.new(role);
+	end
+	role_registry[role.name] = role;
+end
+
+-- Default roles
+register_role {
+	name = "prosody:restricted";
+	priority = 15;
+};
+
+register_role {
+	name = "prosody:user";
+	priority = 25;
+	inherits = { "prosody:restricted" };
+};
 
-function get_user_roles(user)
-	if config_admin_jids:contains(user.."@"..host) then
-		return admin_role;
+register_role {
+	name = "prosody:admin";
+	priority = 50;
+	inherits = { "prosody:user" };
+};
+
+register_role {
+	name = "prosody:operator";
+	priority = 75;
+	inherits = { "prosody:admin" };
+};
+
+
+-- Process custom roles from config
+
+local custom_roles = module:get_option("custom_roles", {});
+for n, role_config in ipairs(custom_roles) do
+	local ok, err = pcall(register_role, role_config);
+	if not ok then
+		module:log("error", "Error registering custom role %s: %s", role_config.name or tostring(n), err);
 	end
-	return role_store:get(user);
+end
+
+-- Process custom permissions from config
+
+local config_add_perms = module:get_option("add_permissions", {});
+local config_remove_perms = module:get_option("remove_permissions", {});
+
+for role_name, added_permissions in pairs(config_add_perms) do
+	if not role_registry[role_name] then
+		module:log("error", "Cannot add permissions to unknown role '%s'", role_name);
+	else
+		for _, permission in ipairs(added_permissions) do
+			role_registry[role_name]:set_permission(permission, true, true);
+		end
+	end
+end
+
+for role_name, removed_permissions in pairs(config_remove_perms) do
+	if not role_registry[role_name] then
+		module:log("error", "Cannot remove permissions from unknown role '%s'", role_name);
+	else
+		for _, permission in ipairs(removed_permissions) do
+			role_registry[role_name]:set_permission(permission, false, true);
+		end
+	end
 end
 
-function set_user_roles(user, roles)
-	role_store:set(user, roles)
-	return true;
+-- Public API
+
+-- 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 role_registry["prosody:operator"];
+	elseif config_admin_jids:contains(bare_jid) then
+		return role_registry["prosody:admin"];
+	end
+
+	-- 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
+	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
+
+-- 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 get_users_with_role(role)
-	local storage_role_users = it.to_array(it.keys(role_map_store:get_all(role) or {}));
-	if role == "prosody:admin" then
-		local config_admin_users = config_admin_jids / function (admin_jid)
+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 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
+
+function user_can_assume_role(user, role_name)
+	local primary_role = get_user_role(user);
+	if primary_role and primary_role.role_name == role_name then
+		return true;
+	end
+	local secondary_roles = get_user_secondary_roles(user);
+	if secondary_roles and secondary_roles[role_name] then
+		return true;
+	end
+	return false;
+end
+
+-- This function is *expensive*
+function get_users_with_role(role_name)
+	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;
+	elseif role_name == "prosody:operator" then
+		config_set = config_global_admin_jids;
+	end
+	if config_set then
+		local config_admin_users = config_set / function (admin_jid)
 			local j_node, j_host = jid_split(admin_jid);
 			if j_host == host then
 				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_roles(jid)
-	if config_admin_jids:contains(jid) then
-		return admin_role;
+function get_jid_role(jid)
+	local bare_jid = jid_bare(jid);
+	if config_global_admin_jids:contains(bare_jid) then
+		return role_registry["prosody:operator"];
+	elseif config_admin_jids:contains(bare_jid) then
+		return role_registry["prosody:admin"];
 	end
 	return nil;
 end
 
-function set_jid_roles(jid) -- luacheck: ignore 212
+function set_jid_role(jid, role_name) -- luacheck: ignore 212
 	return false;
 end
 
-function get_jids_with_role(role)
+function get_jids_with_role(role_name)
 	-- Fetch role users from storage
-	local storage_role_jids = array.map(get_users_with_role(role), function (username)
+	local storage_role_jids = array.map(get_users_with_role(role_name), function (username)
 		return username.."@"..host;
 	end);
-	if role == "prosody:admin" then
+	if role_name == "prosody:admin" then
 		return it.to_array(config_admin_jids + set.new(storage_role_jids));
+	elseif role_name == "prosody:operator" then
+		return it.to_array(config_global_admin_jids + set.new(storage_role_jids));
 	end
 	return storage_role_jids;
 end
+
+function add_default_permission(role_name, action, policy)
+	local role = role_registry[role_name];
+	if not role then
+		module:log("warn", "Attempt to add default permission for unknown role: %s", role_name);
+		return nil, "no-such-role";
+	end
+	if policy == nil then policy = true; end
+	module:log("debug", "Adding policy %s for permission %s on role %s", policy, action, role_name);
+	return role:set_permission(action, policy);
+end
+
+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 19 14:24:31 2022 +0200
+++ b/plugins/mod_c2s.lua	Mon Aug 22 13:53:35 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_disco.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_disco.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -8,7 +8,6 @@
 
 local get_children = require "core.hostmanager".get_children;
 local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
-local um_is_admin = require "core.usermanager".is_admin;
 local jid_split = require "util.jid".split;
 local jid_bare = require "util.jid".bare;
 local st = require "util.stanza"
@@ -162,14 +161,16 @@
 	end
 end);
 
+module:default_permission("prosody:admin", ":be-discovered-admin");
+
 -- Handle disco requests to user accounts
 if module:get_host_type() ~= "local" then	return end -- skip for components
 module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local node = stanza.tags[1].attr.node;
 	local username = jid_split(stanza.attr.to) or origin.username;
-	local is_admin = um_is_admin(stanza.attr.to or origin.full_jid, module.host)
-	if not stanza.attr.to or (expose_admins and is_admin) or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
+	local target_is_admin = module:may(":be-discovered-admin", stanza.attr.to or origin.full_jid);
+	if not stanza.attr.to or (expose_admins and target_is_admin) or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
 		if node and node ~= "" then
 			local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info', node=node});
 			if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
@@ -185,7 +186,7 @@
 		end
 		local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info'});
 		if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
-		if is_admin then
+		if target_is_admin then
 			reply:tag('identity', {category='account', type='admin'}):up();
 		elseif prosody.hosts[module.host].users.name == "anonymous" then
 			reply:tag('identity', {category='account', type='anonymous'}):up();
--- a/plugins/mod_invites_adhoc.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_invites_adhoc.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -2,7 +2,6 @@
 local dataforms = require "util.dataforms";
 local datetime = require "util.datetime";
 local split_jid = require "util.jid".split;
-local usermanager = require "core.usermanager";
 
 local new_adhoc = module:require("adhoc").new;
 
@@ -13,8 +12,7 @@
 -- on the server, use the option above instead.
 local allow_contact_invites = module:get_option_boolean("allow_contact_invites", true);
 
-local allow_user_invite_roles = module:get_option_set("allow_user_invites_by_roles");
-local deny_user_invite_roles = module:get_option_set("deny_user_invites_by_roles");
+module:default_permission(allow_user_invites and "prosody:user" or "prosody:admin", ":invite-users");
 
 local invites;
 if prosody.shutdown then -- COMPAT hack to detect prosodyctl
@@ -42,36 +40,8 @@
 
 -- This is for checking if the specified JID may create invites
 -- that allow people to register accounts on this host.
-local function may_invite_new_users(jid)
-	if usermanager.get_roles then
-		local user_roles = usermanager.get_roles(jid, module.host);
-		if not user_roles then
-			-- User has no roles we can check, just return default
-			return allow_user_invites;
-		end
-
-		if user_roles["prosody:admin"] then
-			return true;
-		end
-		if allow_user_invite_roles then
-			for allowed_role in allow_user_invite_roles do
-				if user_roles[allowed_role] then
-					return true;
-				end
-			end
-		end
-		if deny_user_invite_roles then
-			for denied_role in deny_user_invite_roles do
-				if user_roles[denied_role] then
-					return false;
-				end
-			end
-		end
-	elseif usermanager.is_admin(jid, module.host) then -- COMPAT w/0.11
-		return true; -- Admins may always create invitations
-	end
-	-- No role matches, so whatever the default is
-	return allow_user_invites;
+local function may_invite_new_users(context)
+	return module:may(":invite-users", context);
 end
 
 module:depends("adhoc");
@@ -91,7 +61,7 @@
 					};
 				};
 			end
-			local invite = invites.create_contact(username, may_invite_new_users(data.from), {
+			local invite = invites.create_contact(username, may_invite_new_users(data), {
 				source = data.from
 			});
 			--TODO: check errors
--- a/plugins/mod_pubsub/mod_pubsub.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_pubsub/mod_pubsub.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -1,7 +1,6 @@
 local pubsub = require "util.pubsub";
 local st = require "util.stanza";
 local jid_bare = require "util.jid".bare;
-local usermanager = require "core.usermanager";
 local new_id = require "util.id".medium;
 local storagemanager = require "core.storagemanager";
 local xtemplate = require "util.xtemplate";
@@ -177,9 +176,10 @@
 end);
 
 local admin_aff = module:get_option_string("default_admin_affiliation", "owner");
+module:default_permission("prosody:admin", ":service-admin");
 local function get_affiliation(jid)
 	local bare_jid = jid_bare(jid);
-	if bare_jid == module.host or usermanager.is_admin(bare_jid, module.host) then
+	if bare_jid == module.host or module:may(":service-admin", bare_jid) then
 		return admin_aff;
 	end
 end
--- a/plugins/mod_saslauth.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_saslauth.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -52,7 +52,7 @@
 		module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg });
 		session.sasl_handler = session.sasl_handler:clean_clone();
 	elseif status == "success" then
-		local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.scope);
+		local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.role);
 		if ok then
 			module:fire_event("authentication-success", { session = session });
 			session.sasl_handler = nil;
--- a/plugins/mod_tokenauth.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/mod_tokenauth.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -1,10 +1,19 @@
 local id = require "util.id";
 local jid = require "util.jid";
 local base64 = require "util.encodings".base64;
+local usermanager = require "core.usermanager";
+local generate_identifier = require "util.id".short;
 
 local token_store = module:open_store("auth_tokens", "map");
 
-function create_jid_token(actor_jid, token_jid, token_scope, token_ttl)
+local function select_role(username, host, role)
+	if role then
+		return prosody.hosts[host].authz.get_role_by_name(role);
+	end
+	return usermanager.get_user_role(username, host);
+end
+
+function create_jid_token(actor_jid, token_jid, token_role, token_ttl)
 	token_jid = jid.prep(token_jid);
 	if not actor_jid or token_jid ~= actor_jid and not jid.compare(token_jid, actor_jid) then
 		return nil, "not-authorized";
@@ -21,13 +30,9 @@
 		created = os.time();
 		expires = token_ttl and (os.time() + token_ttl) or nil;
 		jid = token_jid;
-		session = {
-			username = token_username;
-			host = token_host;
-			resource = token_resource;
 
-			auth_scope = token_scope;
-		};
+		resource = token_resource;
+		role = token_role;
 	};
 
 	local token_id = id.long();
@@ -46,11 +51,7 @@
 	return token_id, token_user, token_host;
 end
 
-function get_token_info(token)
-	local token_id, token_user, token_host = parse_token(token);
-	if not token_id then
-		return nil, "invalid-token-format";
-	end
+local function _get_parsed_token_info(token_id, token_user, token_host)
 	if token_host ~= module.host then
 		return nil, "invalid-host";
 	end
@@ -70,6 +71,33 @@
 	return token_info
 end
 
+function get_token_info(token)
+	local token_id, token_user, token_host = parse_token(token);
+	if not token_id then
+		return nil, "invalid-token-format";
+	end
+	return _get_parsed_token_info(token_id, token_user, token_host);
+end
+
+function get_token_session(token, resource)
+	local token_id, token_user, token_host = parse_token(token);
+	if not token_id then
+		return nil, "invalid-token-format";
+	end
+
+	local token_info, err = _get_parsed_token_info(token_id, token_user, token_host);
+	if not token_info then return nil, err; end
+
+	return {
+		username = token_user;
+		host = token_host;
+		resource = token_info.resource or resource or generate_identifier();
+
+		role = select_role(token_user, token_host, token_info.role);
+	};
+end
+
+
 function revoke_token(token)
 	local token_id, token_user, token_host = parse_token(token);
 	if not token_id then
--- a/plugins/muc/hidden.lib.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/muc/hidden.lib.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -8,7 +8,7 @@
 --
 
 local restrict_public = not module:get_option_boolean("muc_room_allow_public", true);
-local um_is_admin = require "core.usermanager".is_admin;
+module:default_permission(restrict_public and "prosody:admin" or "prosody:user", ":create-public-room");
 
 local function get_hidden(room)
 	return room._data.hidden;
@@ -22,8 +22,8 @@
 end
 
 module:hook("muc-config-form", function(event)
-	if restrict_public and not um_is_admin(event.actor, module.host) then
-		-- Don't show option if public rooms are restricted and user is not admin of this host
+	if not module:may(":create-public-room", event.actor) then
+		-- Hide config option if this user is not allowed to create public rooms
 		return;
 	end
 	table.insert(event.form, {
@@ -36,7 +36,7 @@
 end, 100-9);
 
 module:hook("muc-config-submitted/muc#roomconfig_publicroom", function(event)
-	if restrict_public and not um_is_admin(event.actor, module.host) then
+	if not module:may(":create-public-room", event.actor) then
 		return; -- Not allowed
 	end
 	if set_hidden(event.room, not event.value) then
--- a/plugins/muc/mod_muc.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/muc/mod_muc.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -100,7 +100,6 @@
 local jid_bare = require "util.jid".bare;
 local st = require "util.stanza";
 local cache = require "util.cache";
-local um_is_admin = require "core.usermanager".is_admin;
 
 module:require "muc/config_form_sections";
 
@@ -111,21 +110,23 @@
 module:require "muc/hats";
 module:require "muc/lock";
 
-local function is_admin(jid)
-	return um_is_admin(jid, module.host);
-end
+module:default_permissions("prosody:admin", {
+	":automatic-ownership";
+	":create-room";
+	":recreate-destroyed-room";
+});
 
 if module:get_option_boolean("component_admins_as_room_owners", true) then
 	-- Monkey patch to make server admins room owners
 	local _get_affiliation = room_mt.get_affiliation;
 	function room_mt:get_affiliation(jid)
-		if is_admin(jid) then return "owner"; end
+		if module:may(":automatic-ownership", jid) then return "owner"; end
 		return _get_affiliation(self, jid);
 	end
 
 	local _set_affiliation = room_mt.set_affiliation;
 	function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
-		if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end
+		if affiliation ~= "owner" and module:may(":automatic-ownership", jid) then return nil, "modify", "not-acceptable"; end
 		return _set_affiliation(self, actor, jid, affiliation, reason, data);
 	end
 end
@@ -412,6 +413,8 @@
 	end, -10);
 end
 
+module:default_permission("prosody:admin", ":create-room");
+
 do
 	local restrict_room_creation = module:get_option("restrict_room_creation");
 	if restrict_room_creation == true then
@@ -422,7 +425,7 @@
 		module:hook("muc-room-pre-create", function(event)
 			local origin, stanza = event.origin, event.stanza;
 			local user_jid = stanza.attr.from;
-			if not is_admin(user_jid) and not (
+			if not module:may(":create-room", event) and not (
 				restrict_room_creation == "local" and
 				select(2, jid_split(user_jid)) == host_suffix
 			) then
@@ -465,7 +468,7 @@
 
 		if room and room._data.destroyed then
 			if room._data.locked < os.time()
-			or (is_admin(stanza.attr.from) and stanza.name == "presence" and stanza.attr.type == nil) then
+			or (module:may(":recreate-destroyed-room", event) and stanza.name == "presence" and stanza.attr.type == nil) then
 				-- Allow the room to be recreated by admin or after time has passed
 				delete_room(room);
 				room = nil;
--- a/plugins/muc/persistent.lib.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/plugins/muc/persistent.lib.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -8,7 +8,10 @@
 --
 
 local restrict_persistent = not module:get_option_boolean("muc_room_allow_persistent", true);
-local um_is_admin = require "core.usermanager".is_admin;
+module:default_permission(
+	restrict_persistent and "prosody:admin" or "prosody:user",
+	":create-persistent-room"
+);
 
 local function get_persistent(room)
 	return room._data.persistent;
@@ -22,8 +25,8 @@
 end
 
 module:hook("muc-config-form", function(event)
-	if restrict_persistent and not um_is_admin(event.actor, module.host) then
-		-- Don't show option if hidden rooms are restricted and user is not admin of this host
+	if not module:may(":create-persistent-room", event.actor) then
+		-- Hide config option if this user is not allowed to create persistent rooms
 		return;
 	end
 	table.insert(event.form, {
@@ -36,7 +39,7 @@
 end, 100-5);
 
 module:hook("muc-config-submitted/muc#roomconfig_persistentroom", function(event)
-	if restrict_persistent and not um_is_admin(event.actor, module.host) then
+	if not module:may(":create-persistent-room", event.actor) then
 		return; -- Not allowed
 	end
 	if set_persistent(event.room, event.value) then
--- a/teal-src/module.d.tl	Fri Aug 19 14:24:31 2022 +0200
+++ b/teal-src/module.d.tl	Mon Aug 22 13:53:35 2022 +0100
@@ -126,6 +126,11 @@
 	path : string
 	resource_path : string
 
+	-- access control
+	may : function (moduleapi, string, table|string)
+	default_permission : function (string, string)
+	default_permissions : function (string, { string })
+
 	-- methods the module can add
 	load : function ()
 	add_host : function (moduleapi)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/roles.d.tl	Mon Aug 22 13:53:35 2022 +0100
@@ -0,0 +1,32 @@
+local record util_roles
+
+	type context = any
+
+	record Role
+		id : string
+		name : string
+		description : string
+		default : boolean
+		priority : number -- or integer?
+		permissions : { string : boolean }
+
+		may : function (Role, string, context)
+		clone : function (Role, role_config)
+		set_permission : function (Role, string, boolean, boolean)
+	end
+
+	is_role : function (any) : boolean
+
+	record role_config
+		name : string
+		description : string
+		default : boolean
+		priority : number -- or integer?
+		inherits : { Role }
+		permissions : { string : boolean }
+	end
+
+	new : function (role_config, Role) : Role
+end
+
+return util_roles
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/roles.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -0,0 +1,100 @@
+local array = require "util.array";
+local it = require "util.iterators";
+local new_short_id = require "util.id".short;
+
+local role_methods = {};
+local role_mt = {
+	__index = role_methods;
+	__name = "role";
+	__add = nil;
+};
+
+local function is_role(o)
+	local mt = getmetatable(o);
+	return mt == role_mt;
+end
+
+local function _new_may(permissions, inherited_mays)
+	local n_inherited = inherited_mays and #inherited_mays;
+	return function (role, action, context)
+		-- Note: 'role' may be a descendent role, not only the one we're attached to
+		local policy = permissions[action];
+		if policy ~= nil then
+			return policy;
+		end
+		if n_inherited then
+			for i = 1, n_inherited do
+				policy = inherited_mays[i](role, action, context);
+				if policy ~= nil then
+					return policy;
+				end
+			end
+		end
+		return false;
+	end
+end
+
+local permissions_key = {};
+
+-- {
+-- Required:
+--   name = "My fancy role";
+--
+-- Optional:
+--   inherits = { role_obj... }
+--   default = true
+--   priority = 100
+--   permissions = {
+--     ["foo"] = true; -- allow
+--     ["bar"] = false; -- deny
+--   }
+-- }
+local function new(base_config, overrides)
+	local config = setmetatable(overrides or {}, { __index = base_config });
+	local permissions = {};
+	local inherited_mays;
+	if config.inherits then
+		inherited_mays = array.pluck(config.inherits, "may");
+	end
+	local new_role = {
+		id = new_short_id();
+		name = config.name;
+		description = config.description;
+		default = config.default;
+		priority = config.priority;
+		may = _new_may(permissions, inherited_mays);
+		inherits = config.inherits;
+		[permissions_key] = permissions;
+	};
+	local desired_permissions = config.permissions or config[permissions_key];
+	for k, v in pairs(desired_permissions or {}) do
+		permissions[k] = v;
+	end
+	return setmetatable(new_role, role_mt);
+end
+
+function role_methods:clone(overrides)
+	return new(self, overrides);
+end
+
+function role_methods:set_permission(permission_name, policy, overwrite)
+	local permissions = self[permissions_key];
+	if overwrite ~= true and permissions[permission_name] ~= nil and permissions[permission_name] ~= policy then
+		return false, "policy-already-exists";
+	end
+	permissions[permission_name] = policy;
+	return true;
+end
+
+function role_mt.__tostring(self)
+	return ("role<[%s] %s>"):format(self.id or "nil", self.name or "[no name]");
+end
+
+function role_mt.__pairs(self)
+	return it.filter(permissions_key, next, self);
+end
+
+return {
+	is_role = is_role;
+	new = new;
+};
--- a/util/session.lua	Fri Aug 19 14:24:31 2022 +0200
+++ b/util/session.lua	Mon Aug 22 13:53:35 2022 +0100
@@ -57,10 +57,16 @@
 	return session;
 end
 
+local function set_role(session, role)
+	session.role = role;
+end
+
 return {
 	new = new_session;
+
 	set_id = set_id;
 	set_logger = set_logger;
 	set_conn = set_conn;
 	set_send = set_send;
+	set_role = set_role;
 }