diff --git a/interfaces/builtin/systemd_user_control.go b/interfaces/builtin/systemd_user_control.go
new file mode 100644
index 000000000000..80f42677ef8a
--- /dev/null
+++ b/interfaces/builtin/systemd_user_control.go
@@ -0,0 +1,107 @@
+// -*- Mode: Go; indent-tabs-mode: t -*-
+
+/*
+ * Copyright (C) 2024 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 systemdUserControlSummary = `allows to control the user session service manager`
+
+const systemdUserControlBaseDeclarationPlugs = `
+ systemd-user-control:
+ allow-installation: false
+ deny-auto-connection: true
+`
+
+const systemdUserControlBaseDeclarationSlots = `
+ systemd-user-control:
+ allow-installation:
+ slot-snap-type:
+ - core
+ deny-auto-connection: true
+`
+
+const systemdUserControlConnectedPlugAppArmor = `
+# Description: Can control the user session service manager
+
+#include
+#include
+
+# Supporting session boot fully driven by user session systemd
+# and D-Bus activation
+
+# Please note that UpdateActivationEnvironment can alter D-Bus activated services behavior
+# (e.g. by setting LD_PRELOAD)
+# It is thus intended to be restricted only to snaps acting as a desktop session on Ubuntu Core systems
+#
+# For such snaps, it allows the session to pass important variables to other processes in the session
+# (e.g. DISPLAY, WAYLAND_DISPLAY)
+dbus (send)
+ bus=session
+ path={/,/org/freedesktop/DBus}
+ interface=org.freedesktop.DBus
+ member=UpdateActivationEnvironment
+ peer=(label=unconfined),
+
+dbus (send)
+ bus=session
+ path=/org/freedesktop/systemd1
+ interface=org.freedesktop.DBus.Properties
+ member={Set,Get,GetAll}
+ peer=(label=unconfined),
+
+# Please note that SetEnvironment can alter existing units behavior (e.g. by setting LD_PRELOAD)
+# It is thus intended to be restricted only to snaps acting as a desktop session on Ubuntu Core systems
+#
+# For such snaps, it allows the session to pass important variables to other processes in the session
+# (e.g. DISPLAY, WAYLAND_DISPLAY)
+dbus (send)
+ bus=session
+ path=/org/freedesktop/systemd1
+ interface=org.freedesktop.systemd1.Manager
+ member={SetEnvironment,UnsetEnvironment,UnsetAndSetEnvironment}
+ peer=(label=unconfined),
+
+# Allow to introspect the units available in the session
+dbus (send)
+ bus=session
+ path=/org/freedesktop/systemd1
+ interface=org.freedesktop.systemd1.Manager
+ member={Reload,ListUnitFiles,ListUnitFilesByPatterns}
+ peer=(label=unconfined),
+
+# Allow to manage the units available in the session
+# (e.g. to start the target describing the full session, phase parts of the startup)
+dbus (send)
+ bus=session
+ path=/org/freedesktop/systemd1
+ interface=org.freedesktop.systemd1.Manager
+ member={ResetFailed,Reload,StartUnit,StopUnit,RestartUnit}
+ peer=(label=unconfined),
+`
+
+func init() {
+ registerIface(&commonInterface{
+ name: "systemd-user-control",
+ summary: systemdUserControlSummary,
+ implicitOnCore: true,
+ implicitOnClassic: false, // This is meant for use by session snaps on core, no use for apps in classic mode
+ baseDeclarationPlugs: systemdUserControlBaseDeclarationPlugs,
+ baseDeclarationSlots: systemdUserControlBaseDeclarationSlots,
+ connectedPlugAppArmor: systemdUserControlConnectedPlugAppArmor,
+ })
+}
diff --git a/interfaces/builtin/systemd_user_control_test.go b/interfaces/builtin/systemd_user_control_test.go
new file mode 100644
index 000000000000..ec1cdf815d52
--- /dev/null
+++ b/interfaces/builtin/systemd_user_control_test.go
@@ -0,0 +1,89 @@
+// -*- Mode: Go; indent-tabs-mode: t -*-
+
+/*
+ * Copyright (C) 2024 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 SystemdUserControlInterfaceSuite struct {
+ iface interfaces.Interface
+ slotInfo *snap.SlotInfo
+ slot *interfaces.ConnectedSlot
+ plugInfo *snap.PlugInfo
+ plug *interfaces.ConnectedPlug
+}
+
+var _ = Suite(&SystemdUserControlInterfaceSuite{
+ iface: builtin.MustInterface("systemd-user-control"),
+})
+
+func (s *SystemdUserControlInterfaceSuite) SetUpTest(c *C) {
+ var mockPlugSnapInfoYaml = `name: other
+version: 1.0
+apps:
+ app:
+ command: foo
+ plugs: [systemd-user-control]
+`
+ var mockSlotSnapInfoYaml = `name: core
+version: 0
+type: os
+slots:
+ systemd-user-control:
+`
+ s.plug, s.plugInfo = MockConnectedPlug(c, mockPlugSnapInfoYaml, nil, "systemd-user-control")
+ s.slot, s.slotInfo = MockConnectedSlot(c, mockSlotSnapInfoYaml, nil, "systemd-user-control")
+}
+
+func (s *SystemdUserControlInterfaceSuite) TestName(c *C) {
+ c.Assert(s.iface.Name(), Equals, "systemd-user-control")
+}
+
+func (s *SystemdUserControlInterfaceSuite) TestSanitizeSlot(c *C) {
+ c.Assert(interfaces.BeforePrepareSlot(s.iface, s.slotInfo), IsNil)
+}
+
+func (s *SystemdUserControlInterfaceSuite) TestSanitizePlug(c *C) {
+ c.Assert(interfaces.BeforePreparePlug(s.iface, s.plugInfo), IsNil)
+}
+
+func (s *SystemdUserControlInterfaceSuite) TestUsedSecuritySystems(c *C) {
+ // connected plugs have a non-nil security snippet for apparmor
+ appSet, err := interfaces.NewSnapAppSet(s.plug.Snap(), nil)
+ c.Assert(err, IsNil)
+ apparmorSpec := apparmor.NewSpecification(appSet)
+ err = apparmorSpec.AddConnectedPlug(s.iface, s.plug, s.slot)
+ c.Assert(err, IsNil)
+ c.Assert(apparmorSpec.Snippets(), HasLen, 1)
+ c.Assert(apparmorSpec.SnippetForTag("snap.other.app"), testutil.Contains, "bus=session")
+ c.Assert(apparmorSpec.SnippetForTag("snap.other.app"), Not(testutil.Contains), "bus=system")
+ c.Assert(apparmorSpec.SnippetForTag("snap.other.app"), testutil.Contains, "UpdateActivationEnvironment")
+}
+
+func (s *SystemdUserControlInterfaceSuite) 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 efa0862993a5..2ad3b4829485 100644
--- a/interfaces/policy/basedeclaration_test.go
+++ b/interfaces/policy/basedeclaration_test.go
@@ -143,19 +143,20 @@ func (s *baseDeclSuite) TestAutoConnection(c *C) {
// these have more complex or in flux policies and have their
// own separate tests
snowflakes := map[string]bool{
- "content": true,
- "core-support": true,
- "desktop": true,
- "home": true,
- "lxd-support": true,
- "microstack-support": true,
- "multipass-support": true,
- "packagekit-control": true,
- "pkcs11": true,
- "remoteproc": true,
- "snapd-control": true,
- "upower-observe": true,
- "empty": true,
+ "content": true,
+ "core-support": true,
+ "desktop": true,
+ "home": true,
+ "lxd-support": true,
+ "microstack-support": true,
+ "multipass-support": true,
+ "packagekit-control": true,
+ "pkcs11": true,
+ "remoteproc": true,
+ "snapd-control": true,
+ "systemd-user-control": true,
+ "upower-observe": true,
+ "empty": true,
}
// these simply auto-connect, anything else doesn't
@@ -333,6 +334,25 @@ plugs:
c.Check(arity.SlotsPerPlugAny(), Equals, false)
}
+func (s *baseDeclSuite) TestAutoConnectionSystemdUserControl(c *C) {
+ cand := s.connectCand(c, "systemd-user-control", "", "")
+ _, err := cand.CheckAutoConnect()
+ c.Check(err, NotNil)
+ c.Assert(err, ErrorMatches, "auto-connection denied by plug rule of interface \"systemd-user-control\"")
+
+ plugsSlots := `
+plugs:
+ systemd-user-control:
+ allow-auto-connection: true
+`
+
+ lxdDecl := s.mockSnapDecl(c, "some-snap", "J60k4JY0HppjwOjW8dZdYc8obXKxujRu", "canonical", plugsSlots)
+ cand.PlugSnapDeclaration = lxdDecl
+ arity, err := cand.CheckAutoConnect()
+ c.Check(err, IsNil)
+ c.Check(arity.SlotsPerPlugAny(), Equals, false)
+}
+
func (s *baseDeclSuite) TestAutoConnectionContent(c *C) {
// random snaps cannot connect with content
// (Sanitize* will now also block this)
@@ -1040,6 +1060,7 @@ func (s *baseDeclSuite) TestPlugInstallation(c *C) {
"snapd-control": true,
"steam-support": true,
"system-files": true,
+ "systemd-user-control": true,
"tee": true,
"uinput": true,
"unity8": true,
@@ -1341,6 +1362,7 @@ func (s *baseDeclSuite) TestValidity(c *C) {
"snapd-control": true,
"steam-support": true,
"system-files": true,
+ "systemd-user-control": true,
"tee": true,
"udisks2": true,
"uinput": true,
diff --git a/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml b/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml
index a808e59b43df..ef53cb65c59a 100644
--- a/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml
+++ b/tests/lib/snaps/test-snapd-policy-app-consumer/meta/snap.yaml
@@ -290,6 +290,9 @@ apps:
xilinx-dma:
command: bin/run
plugs: [ xilinx-dma ]
+ systemd-user-control:
+ command: bin/run
+ plugs: [ systemd-user-control ]
plugs:
custom-device: