diff --git a/interfaces/builtin/xdg_portal_permission_store.go b/interfaces/builtin/xdg_portal_permission_store.go new file mode 100644 index 00000000000..22407c54afd --- /dev/null +++ b/interfaces/builtin/xdg_portal_permission_store.go @@ -0,0 +1,75 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2026 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package builtin + +const xdgPortalPermissionStoreSummary = `allows access to the XDG Desktop Portal PermissionStore service` + +const xdgPortalPermissionStoreBaseDeclarationPlugs = ` + xdg-portal-permission-store: + allow-installation: false + deny-auto-connection: true +` + +const xdgPortalPermissionStoreBaseDeclarationSlots = ` + xdg-portal-permission-store: + allow-installation: + slot-snap-type: + - core + deny-auto-connection: true +` + +const xdgPortalPermissionStoreConnectedPlugAppArmor = ` +# Description: Allow access to xdg-desktop-portal's PermissionStore service. + +#include + +dbus (receive, send) + bus=session + interface=org.freedesktop.impl.portal.PermissionStore + path=/org/freedesktop/impl/portal/PermissionStore + peer=(label=unconfined), +dbus (receive, send) + bus=session + interface=org.freedesktop.DBus.Properties + path=/org/freedesktop/impl/portal/PermissionStore + peer=(label=unconfined), +dbus (receive, send) + bus=session + interface=org.freedesktop.DBus.Peer + path=/org/freedesktop/impl/portal/PermissionStore + peer=(label=unconfined), +dbus (receive, send) + bus=session + interface=org.freedesktop.DBus.Introspectable + path=/org/freedesktop/impl/portal/PermissionStore + peer=(label=unconfined), +` + +func init() { + registerIface(&commonInterface{ + name: "xdg-portal-permission-store", + summary: xdgPortalPermissionStoreSummary, + implicitOnCore: true, + implicitOnClassic: true, + baseDeclarationPlugs: xdgPortalPermissionStoreBaseDeclarationPlugs, + baseDeclarationSlots: xdgPortalPermissionStoreBaseDeclarationSlots, + connectedPlugAppArmor: xdgPortalPermissionStoreConnectedPlugAppArmor, + }) +} diff --git a/interfaces/builtin/xdg_portal_permission_store_test.go b/interfaces/builtin/xdg_portal_permission_store_test.go new file mode 100644 index 00000000000..351434dd772 --- /dev/null +++ b/interfaces/builtin/xdg_portal_permission_store_test.go @@ -0,0 +1,115 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2026 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package builtin_test + +import ( + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/interfaces" + "github.com/snapcore/snapd/interfaces/apparmor" + "github.com/snapcore/snapd/interfaces/builtin" + "github.com/snapcore/snapd/snap" + "github.com/snapcore/snapd/testutil" +) + +type XdgPortalPermissionStoreInterfaceSuite struct { + iface interfaces.Interface + slot *interfaces.ConnectedSlot + slotInfo *snap.SlotInfo + plug *interfaces.ConnectedPlug + plugInfo *snap.PlugInfo +} + +var _ = Suite(&XdgPortalPermissionStoreInterfaceSuite{ + iface: builtin.MustInterface("xdg-portal-permission-store"), +}) + +func (s *XdgPortalPermissionStoreInterfaceSuite) SetUpTest(c *C) { + const coreYaml = `name: core +version: 0 +type: os +slots: + xdg-portal-permission-store: + interface: xdg-portal-permission-store +` + s.slot, s.slotInfo = MockConnectedSlot(c, coreYaml, nil, "xdg-portal-permission-store") + + const consumerYaml = `name: consumer +version: 0 +apps: + app: + plugs: [xdg-portal-permission-store] +` + s.plug, s.plugInfo = MockConnectedPlug(c, consumerYaml, nil, "xdg-portal-permission-store") +} + +func (s *XdgPortalPermissionStoreInterfaceSuite) TestName(c *C) { + c.Assert(s.iface.Name(), Equals, "xdg-portal-permission-store") +} + +func (s *XdgPortalPermissionStoreInterfaceSuite) TestSanitize(c *C) { + c.Assert(interfaces.BeforePreparePlug(s.iface, s.plugInfo), IsNil) + c.Assert(interfaces.BeforePrepareSlot(s.iface, s.slotInfo), IsNil) +} + +func (s *XdgPortalPermissionStoreInterfaceSuite) TestAppArmorConnectedPlug(c *C) { + appSet, err := interfaces.NewSnapAppSet(s.plug.Snap(), nil) + c.Assert(err, IsNil) + spec := apparmor.NewSpecification(appSet) + c.Assert(spec.AddConnectedPlug(s.iface, s.plug, s.slot), IsNil) + c.Assert(spec.SecurityTags(), DeepEquals, []string{"snap.consumer.app"}) + c.Check(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "#include ") + c.Check(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "path=/org/freedesktop/impl/portal/PermissionStore") + c.Check(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "interface=org.freedesktop.impl.portal.PermissionStore") + c.Check(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "interface=org.freedesktop.DBus.Properties") + c.Check(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "interface=org.freedesktop.DBus.Peer") + c.Check(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "interface=org.freedesktop.DBus.Introspectable") + c.Check(spec.SnippetForTag("snap.consumer.app"), testutil.Contains, "peer=(label=unconfined)") +} + +func (s *XdgPortalPermissionStoreInterfaceSuite) TestAppArmorConnectedSlot(c *C) { + appSet, err := interfaces.NewSnapAppSet(s.slot.Snap(), nil) + c.Assert(err, IsNil) + spec := apparmor.NewSpecification(appSet) + c.Assert(spec.AddConnectedSlot(s.iface, s.plug, s.slot), IsNil) + c.Assert(spec.SecurityTags(), HasLen, 0) +} + +func (s *XdgPortalPermissionStoreInterfaceSuite) TestAppArmorPermanentSlot(c *C) { + spec := &apparmor.Specification{} + c.Assert(spec.AddPermanentSlot(s.iface, s.slotInfo), IsNil) + c.Assert(spec.SecurityTags(), HasLen, 0) +} + +func (s *XdgPortalPermissionStoreInterfaceSuite) TestStaticInfo(c *C) { + si := interfaces.StaticInfoOf(s.iface) + c.Check(si.ImplicitOnCore, Equals, true) + c.Check(si.ImplicitOnClassic, Equals, true) + c.Check(si.Summary, Equals, "allows access to the XDG Desktop Portal PermissionStore service") + c.Check(si.BaseDeclarationPlugs, testutil.Contains, "xdg-portal-permission-store") + c.Check(si.BaseDeclarationPlugs, testutil.Contains, "allow-installation: false") + c.Check(si.BaseDeclarationPlugs, testutil.Contains, "deny-auto-connection: true") + c.Check(si.BaseDeclarationSlots, testutil.Contains, "xdg-portal-permission-store") + c.Check(si.BaseDeclarationSlots, testutil.Contains, "deny-auto-connection: true") +} + +func (s *XdgPortalPermissionStoreInterfaceSuite) TestInterfaces(c *C) { + c.Check(builtin.Interfaces(), testutil.DeepContains, s.iface) +} diff --git a/interfaces/policy/basedeclaration_test.go b/interfaces/policy/basedeclaration_test.go index 1b66ce36b39..1790dfb288e 100644 --- a/interfaces/policy/basedeclaration_test.go +++ b/interfaces/policy/basedeclaration_test.go @@ -1128,6 +1128,7 @@ func (s *baseDeclSuite) TestPlugInstallation(c *C) { "uinput": true, "unity8": true, "ubuntu-pro-control": true, + "xdg-portal-permission-store": true, "userns": true, "xilinx-dma": true, "snap-fde-control": true, @@ -1452,6 +1453,7 @@ func (s *baseDeclSuite) TestValidity(c *C) { "system-files": true, "tee": true, "ubuntu-pro-control": true, + "xdg-portal-permission-store": true, "udisks2": true, "uinput": true, "unity8": true, diff --git a/tests/lib/snaps/test-snapd-xdg-portal-permission-store/bin/client.sh b/tests/lib/snaps/test-snapd-xdg-portal-permission-store/bin/client.sh new file mode 100755 index 00000000000..904aaae8b3f --- /dev/null +++ b/tests/lib/snaps/test-snapd-xdg-portal-permission-store/bin/client.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu + +command="${1:-all}" +bus_name="${DBUS_BUS_NAME}" +object_path="${DBUS_OBJECT_PATH}" +iface_name="${DBUS_IFACE_NAME}" + +cmd_ping() { + # Use --print-reply so dbus-send performs a method call and waits for the + # reply. We discard the reply body because for Ping we only care about the + # success or failure of the round trip. + dbus-send --session --print-reply \ + --dest="$bus_name" \ + "$object_path" \ + org.freedesktop.DBus.Peer.Ping >/dev/null + echo ok +} + +cmd_introspect() { + dbus-send --session --print-reply \ + --dest="$bus_name" \ + "$object_path" \ + org.freedesktop.DBus.Introspectable.Introspect | grep -q "$iface_name" + echo ok +} + +cmd_get_all() { + dbus-send --session --print-reply \ + --dest="$bus_name" \ + "$object_path" \ + org.freedesktop.DBus.Properties.GetAll \ + string:"$iface_name" | grep -q 'uint32 1' + echo ok +} + +cmd_test() { + dbus-send --session --print-reply \ + --dest="$bus_name" \ + "$object_path" \ + "$iface_name".Test | grep -q 'string "ok"' + echo ok +} + +case "$command" in + ping) + cmd_ping + ;; + introspect) + cmd_introspect + ;; + get-all) + cmd_get_all + ;; + test) + cmd_test + ;; + all) + cmd_ping + cmd_introspect + cmd_get_all + cmd_test + ;; + *) + echo "unknown command: $command" >&2 + exit 1 + ;; +esac diff --git a/tests/lib/snaps/test-snapd-xdg-portal-permission-store/meta/snap.yaml b/tests/lib/snaps/test-snapd-xdg-portal-permission-store/meta/snap.yaml new file mode 100644 index 00000000000..9278f72227e --- /dev/null +++ b/tests/lib/snaps/test-snapd-xdg-portal-permission-store/meta/snap.yaml @@ -0,0 +1,29 @@ +name: test-snapd-xdg-portal-permission-store +version: 1.0 +summary: test client for xdg-portal-permission-store +description: ... +apps: + client: + command: bin/client.sh all + plugs: &permission-store-plugs + - xdg-portal-permission-store + environment: &permission-store-env + DBUS_BUS_NAME: org.freedesktop.impl.portal.PermissionStore + DBUS_OBJECT_PATH: /org/freedesktop/impl/portal/PermissionStore + DBUS_IFACE_NAME: org.freedesktop.impl.portal.PermissionStore + ping: + command: bin/client.sh ping + plugs: *permission-store-plugs + environment: *permission-store-env + introspect: + command: bin/client.sh introspect + plugs: *permission-store-plugs + environment: *permission-store-env + get-all: + command: bin/client.sh get-all + plugs: *permission-store-plugs + environment: *permission-store-env + test: + command: bin/client.sh test + plugs: *permission-store-plugs + environment: *permission-store-env diff --git a/tests/main/interfaces-xdg-portal-permission-store/fake-permission-store.py b/tests/main/interfaces-xdg-portal-permission-store/fake-permission-store.py new file mode 100644 index 00000000000..ae34300308a --- /dev/null +++ b/tests/main/interfaces-xdg-portal-permission-store/fake-permission-store.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +import os +import sys + +import dbus +import dbus.mainloop.glib +import dbus.service +from gi.repository import GLib + +BUS_NAME = os.environ["DBUS_BUS_NAME"] +OBJECT_PATH = os.environ["DBUS_OBJECT_PATH"] +PERMISSION_STORE_IFACE = os.environ["DBUS_IFACE_NAME"] + +INTROSPECTION_XML_TEMPLATE = """ + + + + + + + + + + + + + + + + + + + + + +""" + + +class PermissionStore(dbus.service.Object): + def __init__(self, connection, object_path): + super().__init__(connection, object_path) + self._introspection_xml = INTROSPECTION_XML_TEMPLATE.format( + iface_name=PERMISSION_STORE_IFACE + ) + + @dbus.service.method( + dbus_interface=PERMISSION_STORE_IFACE, + in_signature="", + out_signature="s", + ) + def Test(self): + return "ok" + + @dbus.service.method( + dbus_interface="org.freedesktop.DBus.Peer", + in_signature="", + out_signature="", + ) + def Ping(self): + return None + + @dbus.service.method( + dbus_interface="org.freedesktop.DBus.Introspectable", + in_signature="", + out_signature="s", + ) + def Introspect(self): + return self._introspection_xml + + @dbus.service.method( + dbus_interface="org.freedesktop.DBus.Properties", + in_signature="s", + out_signature="a{sv}", + ) + def GetAll(self, iface): + if iface != PERMISSION_STORE_IFACE: + return dbus.Dictionary({}, signature="sv") + return dbus.Dictionary({"version": dbus.UInt32(1)}, signature="sv") + + +def main(argv): + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + main_loop = GLib.MainLoop() + + bus = dbus.SessionBus() + bus.add_signal_receiver( + main_loop.quit, + signal_name="Disconnected", + path="/org/freedesktop/DBus/Local", + dbus_interface="org.freedesktop.DBus.Local", + ) + + bus_name = dbus.service.BusName( + BUS_NAME, + bus, + allow_replacement=True, + replace_existing=True, + do_not_queue=True, + ) + PermissionStore(bus, OBJECT_PATH) + + main_loop.run() + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/main/interfaces-xdg-portal-permission-store/task.yaml b/tests/main/interfaces-xdg-portal-permission-store/task.yaml new file mode 100644 index 00000000000..ab42b4f8a96 --- /dev/null +++ b/tests/main/interfaces-xdg-portal-permission-store/task.yaml @@ -0,0 +1,87 @@ +summary: Ensure that the xdg-portal-permission-store interface works + +details: | + Verify that a snap using the xdg-portal-permission-store interface can + access the xdg-desktop-portal PermissionStore service over the session bus. + + The test starts a fake PermissionStore service outside confinement and then + verifies that a confined snap cannot talk to it while disconnected, but can + call it successfully once the plug is connected. + +# This test starts a user transient unit via `systemd-run --user`, and +# Ubuntu 14.04 has special version of systemd which doesn't have StartTransientUnit API. +# TODO: There seems to be an AppArmor mismatch between the one we have and the +# one in Arch. Disable for now. +systems: [-ubuntu-core-*, -ubuntu-14.04-*, -arch-linux-*] + +environment: + DBUS_BUS_NAME: org.freedesktop.impl.portal.PermissionStore + DBUS_OBJECT_PATH: /org/freedesktop/impl/portal/PermissionStore + DBUS_IFACE_NAME: org.freedesktop.impl.portal.PermissionStore + +skip: + - reason: System does not have a systemd managed D-Bus user session + if: | + ! tests.session has-session-systemd-and-dbus + + - reason: System does not support AppArmor D-Bus mediation + if: | + ! snap debug sandbox-features --required apparmor:kernel:dbus + +prepare: | + tests.session -u test prepare + + echo "Install the PermissionStore test client snap" + "$TESTSTOOLS"/snaps-state install-local test-snapd-xdg-portal-permission-store + + echo "Start the fake PermissionStore service on the session bus" + tests.session -u test exec systemd-run --user \ + --unit test-snapd-permission-store.service \ + env \ + DBUS_BUS_NAME="$DBUS_BUS_NAME" \ + DBUS_OBJECT_PATH="$DBUS_OBJECT_PATH" \ + DBUS_IFACE_NAME="$DBUS_IFACE_NAME" \ + python3 "$(pwd)/fake-permission-store.py" + + echo "Wait for the fake service to become available" + + # $DBUS_BUS_NAME and $DBUS_OBJECT_PATH are set in the test environment. + # shellcheck disable=SC2016 + retry -n 10 --wait 1 sh -c 'tests.session -u test exec dbus-send --session --print-reply --dest="$DBUS_BUS_NAME" "$DBUS_OBJECT_PATH" org.freedesktop.DBus.Peer.Ping >/dev/null 2>&1' +restore: | + tests.session -u test exec systemctl --user stop test-snapd-permission-store.service || true + tests.session -u test restore + +debug: | + tests.session -u test exec systemctl --user status test-snapd-permission-store.service || true + tests.session -u test exec busctl --user list | grep PermissionStore || true + +execute: | + apps="ping introspect get-all test" + + echo "The interface is initially disconnected" + snap interfaces -i xdg-portal-permission-store | MATCH -- '- +test-snapd-xdg-portal-permission-store:xdg-portal-permission-store' + + echo "Without the connection, each PermissionStore access is denied" + for app in $apps; do + not tests.session -u test exec test-snapd-xdg-portal-permission-store."$app" + done + + echo "When the plug is connected" + snap connect test-snapd-xdg-portal-permission-store:xdg-portal-permission-store + + echo "Each PermissionStore access succeeds" + for app in $apps; do + tests.session -u test exec test-snapd-xdg-portal-permission-store."$app" | MATCH '^ok$' + done + + echo "The combined client also succeeds" + tests.session -u test exec test-snapd-xdg-portal-permission-store.client | MATCH '^ok$' + + echo "When the plug is disconnected again" + snap disconnect test-snapd-xdg-portal-permission-store:xdg-portal-permission-store + + echo "Each PermissionStore access is denied again" + for app in $apps; do + not tests.session -u test exec test-snapd-xdg-portal-permission-store."$app" + done