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