changeset 14118:221f3378e7f4

mod_pubsub, util.pubsub: Support for 'authorize' access model
author Matthew Wild <mwild1@gmail.com>
date Tue, 31 Mar 2026 14:18:06 +0100
parents 354427afb638
children 7008869fcce2
files plugins/mod_pubsub/mod_pubsub.lua plugins/mod_pubsub/pubsub.lib.lua spec/scansion/pubsub_authorize.scs util/pubsub.lua
diffstat 4 files changed, 487 insertions(+), 24 deletions(-) [+]
line wrap: on
line diff
--- a/plugins/mod_pubsub/mod_pubsub.lua	Tue Mar 31 14:15:00 2026 +0100
+++ b/plugins/mod_pubsub/mod_pubsub.lua	Tue Mar 31 14:18:06 2026 +0100
@@ -30,6 +30,10 @@
 	return lib_pubsub.handle_pubsub_iq(event, service);
 end
 
+function handle_pubsub_message(event)
+	return lib_pubsub.handle_pubsub_message(event, service);
+end
+
 -- An itemstore supports the following methods:
 --   items(): iterator over (id, item)
 --   get(id): return item with id
@@ -43,6 +47,10 @@
 --   get(node_name)
 --   users(): iterator over (node_name)
 
+local supported_access_models = require "prosody.util.set".new({
+	"open", "whitelist", "authorize"
+});
+
 local max_max_items = module:get_option_integer("pubsub_max_items", 256, 1);
 
 local function tonumber_max_items(n)
@@ -130,8 +138,7 @@
 	if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then
 		return false;
 	end
-	if new_config["access_model"] ~= "whitelist"
-	and new_config["access_model"] ~= "open" then
+	if not supported_access_models:contains(new_config["access_model"]) then
 		return false;
 	end
 	return true;
@@ -173,6 +180,8 @@
 module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq);
 module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq);
 
+module:hook("message/host", handle_pubsub_message);
+
 local function add_disco_features_from_service(service) --luacheck: ignore 431/service
 	for feature in lib_pubsub.get_feature_set(service) do
 		module:add_feature(xmlns_pubsub.."#"..feature);
@@ -243,6 +252,7 @@
 
 	module.environment.service = service;
 	add_disco_features_from_service(service);
+	lib_pubsub.add_service_hooks(service);
 end
 
 function module.save()
--- a/plugins/mod_pubsub/pubsub.lib.lua	Tue Mar 31 14:15:00 2026 +0100
+++ b/plugins/mod_pubsub/pubsub.lib.lua	Tue Mar 31 14:18:06 2026 +0100
@@ -5,6 +5,7 @@
 local st = require "prosody.util.stanza";
 local it = require "prosody.util.iterators";
 local uuid_generate = require "prosody.util.uuid".generate;
+local get_form_type = require "prosody.util.dataforms".get_type;
 local dataform = require"prosody.util.dataforms".new;
 local errors = require "prosody.util.error";
 
@@ -17,6 +18,9 @@
 local handlers = {};
 _M.handlers = handlers;
 
+local form_handlers = {};
+_M.form_handlers = form_handlers;
+
 local pubsub_errors = errors.init("pubsub", xmlns_pubsub_errors, {
 	["conflict"] = { "cancel", "conflict" };
 	["invalid-jid"] = { "modify", "bad-request", nil, "invalid-jid" };
@@ -32,6 +36,7 @@
 	["precondition-not-met"] = { "cancel", "conflict", nil, "precondition-not-met" };
 	["invalid-item"] = { "modify", "bad-request", "invalid item" };
 	["persistent-items-unsupported"] = { "cancel", "feature-not-implemented", nil, "persistent-items" };
+	["already-subscribed"] = { "cancel", "bad-request" };
 });
 local function pubsub_error_reply(stanza, error, context)
 	local err = pubsub_errors.wrap(error, context);
@@ -228,6 +233,32 @@
 };
 _M.node_metadata_form = node_metadata_form;
 
+local sub_approval_form = dataform {
+	{
+		type = "hidden";
+		var = "FORM_TYPE";
+		value = "http://jabber.org/protocol/pubsub#subscribe_authorization";
+	};
+	{
+		type = "text-single";
+		name = "node";
+		var = "pubsub#node";
+		label = "Node name";
+	};
+	{
+		type = "jid-single";
+		name = "jid";
+		var = "pubsub#subscriber_jid";
+		label = "Subscriber JID";
+	};
+	{
+		type = "boolean";
+		name = "allow";
+		var = "pubsub#allow";
+		label = "Allow subscription";
+	};
+};
+
 local service_method_feature_map = {
 	add_subscription = { "subscribe", "subscription-options" };
 	create = { "create-nodes", "instant-nodes", "item-ids", "create-and-configure" };
@@ -269,7 +300,7 @@
 	end
 
 	for affiliation in pairs(service.config.capabilities) do
-		if affiliation ~= "none" and affiliation ~= "owner" then
+		if affiliation ~= "none" and affiliation ~= "unauthorized" and affiliation ~= "owner" then
 			supported_features:add(affiliation.."-affiliation");
 		end
 	end
@@ -337,6 +368,29 @@
 	end
 end
 
+function _M.handle_pubsub_message(event, service)
+	local origin, stanza = event.origin, event.stanza;
+
+	if stanza.attr.type == "error" then
+		return;
+	end
+
+	local form = stanza:get_child("x", "jabber:x:data");
+	if form and form.attr.type == "submit" then
+		local form_type = get_form_type(form);
+		if not form_type then return; end
+
+		local form_name = form_type:match("http://jabber.org/protocol/pubsub#([%w_]+)$");
+		if not form_name then return; end
+
+		local handler = form_handlers[form_name];
+		if not handler then return; end
+
+		handler(origin, stanza, form, service);
+		return true;
+	end
+end
+
 function handlers.get_items(origin, stanza, items, service)
 	local node = items.attr.node;
 
@@ -398,7 +452,7 @@
 
 function handlers.get_subscriptions(origin, stanza, subscriptions, service)
 	local node = subscriptions.attr.node;
-	local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from);
+	local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from, true);
 	if not ok then
 		origin.send(pubsub_error_reply(stanza, ret));
 		return true;
@@ -407,7 +461,7 @@
 		:tag("pubsub", { xmlns = xmlns_pubsub })
 			:tag("subscriptions");
 	for _, sub in ipairs(ret) do
-		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up();
+		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = sub.subscription and 'subscribed' or 'pending' }):up();
 	end
 	origin.send(reply);
 	return true;
@@ -415,17 +469,19 @@
 
 function handlers.owner_get_subscriptions(origin, stanza, subscriptions, service)
 	local node = subscriptions.attr.node;
-	local ok, ret = service:get_subscriptions(node, stanza.attr.from);
+	local ok, ret = service:get_subscriptions(node, stanza.attr.from, nil, true);
 	if not ok then
 		origin.send(pubsub_error_reply(stanza, ret));
 		return true;
 	end
+
 	local reply = st.reply(stanza)
 		:tag("pubsub", { xmlns = xmlns_pubsub_owner })
 			:tag("subscriptions");
 	for _, sub in ipairs(ret) do
-		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up();
+		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = sub.subscription and 'subscribed' or 'pending' }):up();
 	end
+
 	origin.send(reply);
 	return true;
 end
@@ -545,7 +601,22 @@
 			return true
 		end
 	end
-	local ok, ret = service:add_subscription(node, stanza.attr.from, jid, options);
+
+	-- Test whether we can subscribe or need to request a subscription
+	-- Known issue: this test fails if we are subscribing someone else,
+	-- but it would be an unusual case to have permission to do that but
+	-- not subscribe ourselves.
+	local ok, ret, success_state;
+	if service:may(node, stanza.attr.from, "subscribe") then
+		module:log("debug", "Subscribing to %s", node);
+		success_state = "subscribed";
+		ok, ret = service:add_subscription(node, stanza.attr.from, jid, options);
+	else
+		module:log("debug", "Requesting subscription to %s (%q %q)", node, stanza.attr.from, jid);
+		success_state = "pending";
+		ok, ret = service:request_subscription(node, stanza.attr.from, jid, options);
+	end
+
 	local reply;
 	if ok then
 		reply = st.reply(stanza)
@@ -553,7 +624,7 @@
 				:tag("subscription", {
 					node = node,
 					jid = jid,
-					subscription = "subscribed"
+					subscription = success_state;
 				}):up();
 		if options_tag then
 			reply:add_child(options_tag);
@@ -871,7 +942,9 @@
 		local affiliation = affiliation_tag.attr.affiliation;
 
 		jid = jid_prep(jid);
-		if affiliation == "none" then affiliation = nil; end
+		if affiliation == "none" or affiliation == "unauthorized" then
+			affiliation = nil;
+		end
 
 		local ok, err = service:set_affiliation(node, stanza.attr.from, jid, affiliation);
 		if not ok then
@@ -973,4 +1046,73 @@
 end
 _M.archive_itemstore = archive_itemstore;
 
+local function notify_subscription_request(event)
+	local service, node = event.service, event.node;
+
+	local node_obj = service.nodes[node];
+	if not node_obj then
+		return;
+	end
+
+	local notify_jids = {};
+
+	for jid, affiliation in pairs(node_obj.affiliations) do
+		if affiliation == "owner" then
+			table.insert(notify_jids, jid);
+		end
+	end
+
+	if #notify_jids == 0 then
+		return;
+	end
+
+	local notification = st.message({ from = module.host })
+		:add_child(sub_approval_form:form({
+			node = node;
+			jid = event.jid;
+			allow = false;
+		}));
+
+	module:log("debug", "Sending subscription request notification to %d JIDs", #notify_jids);
+
+	if #notify_jids == 1 then
+		notification.attr.to = notify_jids[1];
+		module:send(notification);
+		return;
+	end
+
+	for _, recipient in ipairs(notify_jids) do
+		local clone = st.clone(notification);
+		clone.attr.to = recipient;
+		module:send(clone);
+	end
+end
+
+function _M.form_handlers.subscribe_authorization(origin, stanza, form, service)
+	local form_data, errs = sub_approval_form:data(form);
+	if not form_data then
+		local reply = st.error_reply(stanza, "modify", "not-acceptable", dataform_error_message(errs));
+		origin.send(reply);
+		return;
+	end
+
+	local node, jid, allow = form_data.node, form_data.jid, form_data.allow;
+
+	local ok, ret;
+	if allow then
+		ok, ret = service:add_subscription(node, stanza.attr.from, jid);
+	else
+		ok, ret = service:remove_subscription(node, stanza.attr.from, jid);
+	end
+
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, ret));
+		return;
+	end
+end
+
+function _M.add_service_hooks(service)
+	module:hook_object_event(service.events, "subscription-requested", notify_subscription_request);
+end
+
 return _M;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_authorize.scs	Tue Mar 31 14:18:06 2026 +0100
@@ -0,0 +1,158 @@
+# Pubsub: Node 'authorize' access model
+
+[Client] Balthasar
+	jid: admin@localhost
+	password: password
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Balthasar connects
+
+Balthasar sends:
+	<presence/>
+
+Balthasar receives:
+	<presence/>
+
+Balthasar sends:
+	<iq type='set' to='pubsub.localhost' id='create2'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<create node='secret_musings'/>
+			<configure>
+				<x xmlns="jabber:x:data" type="submit">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field var="pubsub#access_model">
+						<value>authorize</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq type="result" id='create2'/>
+
+Juliet connects
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="secret_musings" jid="${Romeo's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="error" id='sub1'/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="secret_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='sub2'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<subscription jid="${Juliet's full JID}" node='secret_musings' subscription='pending'/>
+		</pubsub>
+	</iq>
+
+Juliet sends:
+	<iq type="get" to="pubsub.localhost" id="sub3">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscriptions/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" from="pubsub.localhost" id="sub3">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscriptions>
+				<subscription jid="${Juliet's full JID}" node='secret_musings' subscription='pending'/>
+			</subscriptions>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<message from="pubsub.localhost">
+		<x xmlns="jabber:x:data" type="form">
+			<field var="FORM_TYPE" type="hidden">
+				<value>http://jabber.org/protocol/pubsub#subscribe_authorization</value>
+			</field>
+			<field var="pubsub#node" type="text-single" label='Node name'>
+				<value>secret_musings</value>
+			</field>
+			<field var="pubsub#subscriber_jid" type="jid-single" label='Subscriber JID'>
+				<value>${Juliet's full JID}</value>
+			</field>
+			<field var="pubsub#allow" type="boolean" label='Allow subscription'>
+				<value>0</value>
+			</field>
+		</x>
+	</message>
+
+Balthasar sends:
+	<iq type="get" to="pubsub.localhost" id="subman0">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions node="secret_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq from="pubsub.localhost" id="subman0" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions>
+				<subscription node='secret_musings' subscription="pending" jid="${Juliet's full JID}"/>
+			</subscriptions>
+		</pubsub>
+	</iq>
+
+Balthasar sends:
+	<message to="pubsub.localhost">
+		<x xmlns="jabber:x:data" type="submit">
+			<field var="FORM_TYPE" type="hidden">
+				<value>http://jabber.org/protocol/pubsub#subscribe_authorization</value>
+			</field>
+			<field var="pubsub#node" type="text-single">
+				<value>secret_musings</value>
+			</field>
+			<field var="pubsub#subscriber_jid" type="jid-single">
+				<value>${Juliet's full JID}</value>
+			</field>
+			<field var="pubsub#allow" type="boolean">
+				<value>true</value>
+			</field>
+		</x>
+	</message>
+
+
+Balthasar sends:
+	<iq type="get" to="pubsub.localhost" id="subman1">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions node="secret_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq from="pubsub.localhost" id="subman1" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions>
+				<subscription subscription="subscribed" node='secret_musings' jid="${Juliet's full JID}"/>
+			</subscriptions>
+		</pubsub>
+	</iq>
+
+// vim: syntax=xml:
--- a/util/pubsub.lua	Tue Mar 31 14:15:00 2026 +0100
+++ b/util/pubsub.lua	Tue Mar 31 14:18:06 2026 +0100
@@ -34,8 +34,39 @@
 			be_subscribed = false;
 			be_unsubscribed = true;
 
+			request_subscribe = false;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
+		-- 'unauthorized' is the default for unaffiliated in access_model 'authorize'
+		unauthorized = {
+			create = false;
+			publish = false;
+			retract = false;
+			get_nodes = true;
+
+			subscribe = false;
+			unsubscribe = true;
+			get_subscription = true;
+			get_subscriptions = true;
+			get_items = false;
+			get_metadata = true;
+
+			subscribe_other = false;
+			unsubscribe_other = false;
+			get_subscription_other = false;
+			get_subscriptions_other = false;
+
+			be_subscribed = true;
+			be_unsubscribed = true;
+
+			request_subscribe = true;
+			request_subscribe_other = false;
+
+			set_affiliation = false;
+		};
+		-- default for unaffiliated in open access_model
 		none = {
 			create = false;
 			publish = false;
@@ -57,6 +88,9 @@
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
 		member = {
@@ -80,6 +114,9 @@
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
 		publisher = {
@@ -104,6 +141,9 @@
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
 		owner = {
@@ -128,9 +168,14 @@
 			get_subscription_other = true;
 			get_subscriptions_other = true;
 
+			get_pending_subscriptions = true;
+
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = true;
+
 			set_affiliation = true;
 		};
 	};
@@ -151,6 +196,7 @@
 local function load_node_from_store(service, node_name)
 	local node = service.config.nodestore:get(node_name);
 	node.config = setmetatable(node.config or {}, {__index=service.node_defaults});
+	node.pending_subscribers = node.pending_subscribers or {};
 	return node;
 end
 
@@ -159,6 +205,7 @@
 		name = node.name;
 		config = node.config;
 		subscribers = node.subscribers;
+		pending_subscribers = node.pending_subscribers;
 		affiliations = node.affiliations;
 	});
 end
@@ -263,6 +310,8 @@
 		return "member";
 	elseif access_model == "whitelist" then
 		return "outcast";
+	elseif access_model == "authorize" then
+		return "unauthorized";
 	end
 
 	if self.config.access_models then
@@ -314,6 +363,66 @@
 	return true;
 end
 
+function service:request_subscription(node, actor, jid, options) --> ok, err
+	-- Access checking
+	local cap;
+	if actor == true or jid == actor or self:jids_equal(actor, jid) then
+		cap = "request_subscribe";
+	else
+		cap = "request_subscribe_other";
+	end
+	if not self:may(node, actor, cap) then
+		return false, "forbidden";
+	end
+	if not self:may(node, jid, "be_subscribed") then
+		return false, "forbidden";
+	end
+	--
+	local node_obj = self.nodes[node];
+	if not node_obj then
+		if not self.config.autocreate_on_subscribe then
+			return false, "item-not-found";
+		else
+			local ok, err = self:create(node, true);
+			if not ok then
+				return ok, err;
+			end
+			node_obj = self.nodes[node];
+		end
+	end
+	local old_subscription = node_obj.subscribers[jid];
+	if old_subscription then
+		return false, "already-subscribed";
+	end
+	node_obj.pending_subscribers[jid] = options or true;
+
+	local normal_jid = self.config.normalize_jid(jid);
+	local subs = self.subscriptions[normal_jid];
+	if subs then
+		if not subs[jid] then
+			subs[jid] = { [node] = false };
+		else
+			subs[jid][node] = false;
+		end
+	else
+		self.subscriptions[normal_jid] = { [jid] = { [node] = false } };
+	end
+
+	if self.config.nodestore then
+		-- TODO pass the error from storage to caller eg wrapped in an util.error
+		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
+		if not ok then
+			-- Store failed, so clear in-memory structures
+			node_obj.pending_subscribers[jid] = nil;
+			self.subscriptions[normal_jid][jid][node] = nil;
+			return ok, "internal-server-error";
+		end
+	end
+
+	self.events.fire_event("subscription-requested", { service = self, node = node, jid = jid, normalized_jid = normal_jid, options = options });
+	return true;
+end
+
 function service:add_subscription(node, actor, jid, options) --> ok, err
 	-- Access checking
 	local cap;
@@ -342,7 +451,18 @@
 		end
 	end
 	local old_subscription = node_obj.subscribers[jid];
+	local pending_subscription = node_obj.pending_subscribers[jid];
+
+	if not options and pending_subscription then
+		-- Use the pending subscription request's options now we
+		-- are upgrading to a real subscription.
+		options = pending_subscription;
+	end
 	node_obj.subscribers[jid] = options or true;
+
+	-- Clear pending subscription request, if any
+	node_obj.pending_subscribers[jid] = nil;
+
 	local normal_jid = self.config.normalize_jid(jid);
 	local subs = self.subscriptions[normal_jid];
 	if subs then
@@ -359,8 +479,10 @@
 		-- TODO pass the error from storage to caller eg wrapped in an util.error
 		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
+			-- Store failed, revert in-memory to old subscription
 			node_obj.subscribers[jid] = old_subscription;
 			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
+			self.pending_subscribers[normal_jid] = pending_subscription;
 			return ok, "internal-server-error";
 		end
 	end
@@ -388,11 +510,17 @@
 	if not node_obj then
 		return false, "item-not-found";
 	end
-	if not node_obj.subscribers[jid] then
+
+	local old_subscription = node_obj.subscribers[jid];
+	local pending_subscription = node_obj.pending_subscribers[jid];
+
+	if not old_subscription and not pending_subscription then
 		return false, "not-subscribed";
 	end
-	local old_subscription = node_obj.subscribers[jid];
+
 	node_obj.subscribers[jid] = nil;
+	node_obj.pending_subscribers[jid] = nil;
+
 	local normal_jid = self.config.normalize_jid(jid);
 	local subs = self.subscriptions[normal_jid];
 	if subs then
@@ -412,8 +540,14 @@
 		-- TODO pass the error from storage to caller eg wrapped in an util.error
 		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
+			-- Restore previous in-memory state
 			node_obj.subscribers[jid] = old_subscription;
-			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
+			node_obj.pending_subscribers[jid] = pending_subscription;
+			if pending_subscription then
+				self.subscriptions[normal_jid][jid][node] = false;
+			elseif old_subscription then
+				self.subscriptions[normal_jid][jid][node] = true;
+			end
 			return ok, "internal-server-error";
 		end
 	end
@@ -441,6 +575,21 @@
 	return true, node_obj.subscribers[jid];
 end
 
+function service:get_pending_subscriptions(node, actor)
+	-- Access checking
+	if not self:may(node, actor, "get_pending_subscriptions") then
+		return false, "forbidden";
+	end
+	--
+
+	local node_obj = self.nodes[node];
+	if not node_obj then
+		return false, "item-not-found";
+	end
+
+	return true, node_obj.pending_subscribers;
+end
+
 function service:create(node, actor, options) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "create") then
@@ -463,6 +612,7 @@
 	self.nodes[node] = {
 		name = node;
 		subscribers = {};
+		pending_subscribers = {};
 		config = config;
 		affiliations = {};
 	};
@@ -739,10 +889,11 @@
 	return true, self.nodes;
 end
 
-local function flatten_subscriptions(ret, serv, subs, node, node_obj)
+local function flatten_subscriptions(ret, serv, subs, node, node_obj, inc_pending)
 	for subscribed_jid, subscribed_nodes in pairs(subs) do
 		if node then -- Return only subscriptions to this node
-			if subscribed_nodes[node] then
+			local state = subscribed_nodes[node];
+			if state or (state == false and inc_pending) then
 				ret[#ret+1] = {
 					node = node;
 					jid = subscribed_jid;
@@ -751,18 +902,20 @@
 			end
 		else -- Return subscriptions to all nodes
 			local nodes = serv.nodes;
-			for subscribed_node in pairs(subscribed_nodes) do
-				ret[#ret+1] = {
-					node = subscribed_node;
-					jid = subscribed_jid;
-					subscription = nodes[subscribed_node].subscribers[subscribed_jid];
-				};
+			for subscribed_node, state in pairs(subscribed_nodes) do
+				if state or inc_pending then
+					ret[#ret+1] = {
+						node = subscribed_node;
+						jid = subscribed_jid;
+						subscription = nodes[subscribed_node].subscribers[subscribed_jid];
+					};
+				end
 			end
 		end
 	end
 end
 
-function service:get_subscriptions(node, actor, jid) --> (true, array) or (false, err)
+function service:get_subscriptions(node, actor, jid, include_pending) --> (true, array) or (false, err)
 	-- Access checking
 	local cap;
 	if actor == true or jid == actor or self:jids_equal(actor, jid) then
@@ -784,7 +937,7 @@
 	local ret = {};
 	if jid == nil then
 		for _, subs in pairs(self.subscriptions) do
-			flatten_subscriptions(ret, self, subs, node, node_obj)
+			flatten_subscriptions(ret, self, subs, node, node_obj, include_pending)
 		end
 		return true, ret;
 	end
@@ -793,7 +946,7 @@
 	-- We return the subscription object from the node to save
 	-- a get_subscription() call for each node.
 	if subs then
-		flatten_subscriptions(ret, self, subs, node, node_obj)
+		flatten_subscriptions(ret, self, subs, node, node_obj, include_pending)
 	end
 	return true, ret;
 end