Mercurial > prosody-hg
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
