Blob Blame History Raw
From d3db46cf7f812ebf2badc74d42034c381d5d6223 Mon Sep 17 00:00:00 2001
From: Daan De Meyer <daan.j.demeyer@gmail.com>
Date: Mon, 9 Sep 2024 12:25:28 +0200
Subject: [PATCH] core: Add support for PrivateUsers=identity

This configures an indentity mapping similar to
systemd-nspawn --private-users=identity.
---
 man/org.freedesktop.systemd1.xml         | 44 ++++++++++++++----
 man/systemd.exec.xml                     | 35 ++++++++++-----
 src/core/dbus-execute.c                  | 57 ++++++++++++++++++++++--
 src/core/exec-invoke.c                   | 54 +++++++++++++++-------
 src/core/execute-serialize.c             |  9 ++--
 src/core/execute.c                       |  2 +-
 src/core/execute.h                       |  2 +-
 src/core/load-fragment-gperf.gperf.in    |  2 +-
 src/core/load-fragment.c                 |  1 +
 src/core/load-fragment.h                 |  1 +
 src/core/namespace.c                     |  8 ++++
 src/core/namespace.h                     | 11 +++++
 src/shared/bus-unit-util.c               |  1 +
 test/units/TEST-07-PID1.private-users.sh | 12 +++++
 14 files changed, 191 insertions(+), 48 deletions(-)
 create mode 100755 test/units/TEST-07-PID1.private-users.sh

diff --git a/man/org.freedesktop.systemd1.xml b/man/org.freedesktop.systemd1.xml
index 3bf7ed0bd091b..373354050aefd 100644
--- a/man/org.freedesktop.systemd1.xml
+++ b/man/org.freedesktop.systemd1.xml
@@ -3244,6 +3244,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2eservice {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateUsers = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly s PrivateUsersEx = '...';
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateMounts = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateIPC = ...;
@@ -3861,6 +3863,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2eservice {
 
     <!--property PrivateUsers is not documented!-->
 
+    <!--property PrivateUsersEx is not documented!-->
+
     <!--property PrivateMounts is not documented!-->
 
     <!--property PrivateIPC is not documented!-->
@@ -4557,6 +4561,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2eservice {
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsers"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsersEx"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateMounts"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateIPC"/>
@@ -5385,6 +5391,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2esocket {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateUsers = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly s PrivateUsersEx = '...';
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateMounts = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateIPC = ...;
@@ -6014,6 +6022,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2esocket {
 
     <!--property PrivateUsers is not documented!-->
 
+    <!--property PrivateUsersEx is not documented!-->
+
     <!--property PrivateMounts is not documented!-->
 
     <!--property PrivateIPC is not documented!-->
@@ -6684,6 +6694,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2esocket {
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsers"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsersEx"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateMounts"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateIPC"/>
@@ -7376,6 +7388,8 @@ node /org/freedesktop/systemd1/unit/home_2emount {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateUsers = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly s PrivateUsersEx = '...';
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateMounts = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateIPC = ...;
@@ -7931,6 +7945,8 @@ node /org/freedesktop/systemd1/unit/home_2emount {
 
     <!--property PrivateUsers is not documented!-->
 
+    <!--property PrivateUsersEx is not documented!-->
+
     <!--property PrivateMounts is not documented!-->
 
     <!--property PrivateIPC is not documented!-->
@@ -8513,6 +8529,8 @@ node /org/freedesktop/systemd1/unit/home_2emount {
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsers"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsersEx"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateMounts"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateIPC"/>
@@ -9328,6 +9346,8 @@ node /org/freedesktop/systemd1/unit/dev_2dsda3_2eswap {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateUsers = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly s PrivateUsersEx = '...';
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateMounts = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly b PrivateIPC = ...;
@@ -9869,6 +9889,8 @@ node /org/freedesktop/systemd1/unit/dev_2dsda3_2eswap {
 
     <!--property PrivateUsers is not documented!-->
 
+    <!--property PrivateUsersEx is not documented!-->
+
     <!--property PrivateMounts is not documented!-->
 
     <!--property PrivateIPC is not documented!-->
@@ -10437,6 +10459,8 @@ node /org/freedesktop/systemd1/unit/dev_2dsda3_2eswap {
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsers"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="PrivateUsersEx"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateMounts"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="PrivateIPC"/>
@@ -12173,8 +12197,9 @@ $ gdbus introspect --system --dest org.freedesktop.systemd1 \
       <varname>StatusVarlinkError</varname>,
       <varname>LiveMountResult</varname>,
       <varname>PrivateTmpEx</varname>,
-      <varname>ImportCredentialEx</varname>, and
-      <varname>BindLogSockets</varname> were added in version 257.</para>
+      <varname>ImportCredentialEx</varname>,
+      <varname>BindLogSockets</varname>, and
+      <varname>PrivateUsersEx</varname> were added in version 257.</para>
     </refsect2>
     <refsect2>
       <title>Socket Unit Objects</title>
@@ -12212,8 +12237,9 @@ $ gdbus introspect --system --dest org.freedesktop.systemd1 \
       <varname>MemoryZSwapWriteback</varname>, and
       <varname>PassFileDescriptorsToExec</varname> were added in version 256.</para>
       <para><varname>PrivateTmpEx</varname>,
-      <varname>ImportCredentialEx</varname>, and
-      <varname>BindLogSockets</varname> were added in version 257.</para>
+      <varname>ImportCredentialEx</varname>,
+      <varname>BindLogSockets</varname>, and
+      <varname>PrivateUsersEx</varname> were added in version 257.</para>
     </refsect2>
     <refsect2>
       <title>Mount Unit Objects</title>
@@ -12248,8 +12274,9 @@ $ gdbus introspect --system --dest org.freedesktop.systemd1 \
       <varname>EffectiveTasksMax</varname>, and
       <varname>MemoryZSwapWriteback</varname> were added in version 256.</para>
       <para><varname>PrivateTmpEx</varname>,
-      <varname>ImportCredentialEx</varname>, and
-      <varname>BindLogSockets</varname> were added in version 257.</para>
+      <varname>ImportCredentialEx</varname>,
+      <varname>BindLogSockets</varname>, and
+      <varname>PrivateUsersEx</varname> were added in version 257.</para>
     </refsect2>
     <refsect2>
       <title>Swap Unit Objects</title>
@@ -12284,8 +12311,9 @@ $ gdbus introspect --system --dest org.freedesktop.systemd1 \
       <varname>EffectiveTasksMax</varname>, and
       <varname>MemoryZSwapWriteback</varname> were added in version 256.</para>
       <para><varname>PrivateTmpEx</varname>,
-      <varname>ImportCredentialEx</varname>, and
-      <varname>BindLogSockets</varname> were added in version 257.</para>
+      <varname>ImportCredentialEx</varname>,
+      <varname>BindLogSockets</varname>, and
+      <varname>PrivateUsersEx</varname> were added in version 257.</para>
     </refsect2>
     <refsect2>
       <title>Slice Unit Objects</title>
diff --git a/man/systemd.exec.xml b/man/systemd.exec.xml
index 232ae7e96ec7a..2ff49ff854104 100644
--- a/man/systemd.exec.xml
+++ b/man/systemd.exec.xml
@@ -1966,18 +1966,29 @@ BindReadOnlyPaths=/var/lib/systemd</programlisting>
       <varlistentry>
         <term><varname>PrivateUsers=</varname></term>
 
-        <listitem><para>Takes a boolean argument. If true, sets up a new user namespace for the executed processes and
-        configures a minimal user and group mapping, that maps the <literal>root</literal> user and group as well as
-        the unit's own user and group to themselves and everything else to the <literal>nobody</literal> user and
-        group. This is useful to securely detach the user and group databases used by the unit from the rest of the
-        system, and thus to create an effective sandbox environment. All files, directories, processes, IPC objects and
-        other resources owned by users/groups not equaling <literal>root</literal> or the unit's own will stay visible
-        from within the unit but appear owned by the <literal>nobody</literal> user and group. If this mode is enabled,
-        all unit processes are run without privileges in the host user namespace (regardless if the unit's own
-        user/group is <literal>root</literal> or not). Specifically this means that the process will have zero process
-        capabilities on the host's user namespace, but full capabilities within the service's user namespace. Settings
-        such as <varname>CapabilityBoundingSet=</varname> will affect only the latter, and there's no way to acquire
-        additional capabilities in the host's user namespace. Defaults to off.</para>
+        <listitem><para>Takes a boolean argument or one of <literal>self</literal> or
+        <literal>identity</literal>. Defaults to off. If enabled, sets up a new user namespace for the
+        executed processes and configures a user and group mapping. If set to a true value or
+        <literal>self</literal>, a minimal user and group mapping is configured that maps the
+        <literal>root</literal> user and group as well as the unit's own user and group to themselves and
+        everything else to the <literal>nobody</literal> user and group. This is useful to securely detach
+        the user and group databases used by the unit from the rest of the system, and thus to create an
+        effective sandbox environment. All files, directories, processes, IPC objects and other resources
+        owned by users/groups not equaling <literal>root</literal> or the unit's own will stay visible from
+        within the unit but appear owned by the <literal>nobody</literal> user and group. </para>
+
+        <para>If the parameter is <literal>identity</literal>, user namespacing is set up with an identity
+        mapping for the first 65536 UIDs/GIDs. Any UIDs/GIDs above 65536 will be mapped to the
+        <literal>nobody</literal> user and group, respectively. While this does not provide UID/GID isolation,
+        since all UIDs/GIDs are chosen identically it does provide process capability isolation, and hence is
+        often a good choice if proper user namespacing with distinct UID maps is not appropriate.</para>
+
+        <para>If this mode is enabled, all unit processes are run without privileges in the host user
+        namespace (regardless if the unit's own user/group is <literal>root</literal> or not). Specifically
+        this means that the process will have zero process capabilities on the host's user namespace, but
+        full capabilities within the service's user namespace. Settings such as
+        <varname>CapabilityBoundingSet=</varname> will affect only the latter, and there's no way to acquire
+        additional capabilities in the host's user namespace.</para>
 
         <para>When this setting is set up by a per-user instance of the service manager, the mapping of the
         <literal>root</literal> user and group to itself is omitted (unless the user manager is root).
diff --git a/src/core/dbus-execute.c b/src/core/dbus-execute.c
index 6023436b90a48..51882698b6d7a 100644
--- a/src/core/dbus-execute.c
+++ b/src/core/dbus-execute.c
@@ -60,6 +60,7 @@ static BUS_DEFINE_PROPERTY_GET2(property_get_ioprio_class, "i", ExecContext, exe
 static BUS_DEFINE_PROPERTY_GET2(property_get_ioprio_priority, "i", ExecContext, exec_context_get_effective_ioprio, ioprio_prio_data);
 static BUS_DEFINE_PROPERTY_GET_GLOBAL(property_get_empty_string, "s", NULL);
 static BUS_DEFINE_PROPERTY_GET_REF(property_get_private_tmp_ex, "s", PrivateTmp, private_tmp_to_string);
+static BUS_DEFINE_PROPERTY_GET_REF(property_get_private_users_ex, "s", PrivateUsers, private_users_to_string);
 static BUS_DEFINE_PROPERTY_GET_REF(property_get_syslog_level, "i", int, LOG_PRI);
 static BUS_DEFINE_PROPERTY_GET_REF(property_get_syslog_facility, "i", int, LOG_FAC);
 static BUS_DEFINE_PROPERTY_GET(property_get_cpu_affinity_from_numa, "b", ExecContext, exec_context_get_cpu_affinity_from_numa);
@@ -1027,6 +1028,21 @@ static int property_get_private_tmp(
         return sd_bus_message_append_basic(reply, 'b', &b);
 }
 
+static int property_get_private_users(
+                sd_bus *bus,
+                const char *path,
+                const char *interface,
+                const char *property,
+                sd_bus_message *reply,
+                void *userdata,
+                sd_bus_error *error) {
+
+        PrivateUsers *p = ASSERT_PTR(userdata);
+        int b = *p != PRIVATE_USERS_OFF;
+
+        return sd_bus_message_append_basic(reply, 'b', &b);
+}
+
 const sd_bus_vtable bus_exec_vtable[] = {
         SD_BUS_VTABLE_START(0),
         SD_BUS_PROPERTY("Environment", "as", NULL, offsetof(ExecContext, environment), SD_BUS_VTABLE_PROPERTY_CONST),
@@ -1149,7 +1165,8 @@ const sd_bus_vtable bus_exec_vtable[] = {
         SD_BUS_PROPERTY("ProtectKernelLogs", "b", bus_property_get_bool, offsetof(ExecContext, protect_kernel_logs), SD_BUS_VTABLE_PROPERTY_CONST),
         SD_BUS_PROPERTY("ProtectControlGroups", "b", bus_property_get_bool, offsetof(ExecContext, protect_control_groups), SD_BUS_VTABLE_PROPERTY_CONST),
         SD_BUS_PROPERTY("PrivateNetwork", "b", bus_property_get_bool, offsetof(ExecContext, private_network), SD_BUS_VTABLE_PROPERTY_CONST),
-        SD_BUS_PROPERTY("PrivateUsers", "b", bus_property_get_bool, offsetof(ExecContext, private_users), SD_BUS_VTABLE_PROPERTY_CONST),
+        SD_BUS_PROPERTY("PrivateUsers", "b", property_get_private_users, offsetof(ExecContext, private_users), SD_BUS_VTABLE_PROPERTY_CONST),
+        SD_BUS_PROPERTY("PrivateUsersEx", "s", property_get_private_users_ex, offsetof(ExecContext, private_users), SD_BUS_VTABLE_PROPERTY_CONST),
         SD_BUS_PROPERTY("PrivateMounts", "b", bus_property_get_tristate, offsetof(ExecContext, private_mounts), SD_BUS_VTABLE_PROPERTY_CONST),
         SD_BUS_PROPERTY("PrivateIPC", "b", bus_property_get_bool, offsetof(ExecContext, private_ipc), SD_BUS_VTABLE_PROPERTY_CONST),
         SD_BUS_PROPERTY("ProtectHome", "s", property_get_protect_home, offsetof(ExecContext, protect_home), SD_BUS_VTABLE_PROPERTY_CONST),
@@ -1857,6 +1874,41 @@ int bus_exec_context_set_transient_property(
                 return 1;
         }
 
+        if (streq(name, "PrivateUsers")) {
+                int v;
+
+                r = sd_bus_message_read(message, "b", &v);
+                if (r < 0)
+                        return r;
+
+                if (!UNIT_WRITE_FLAGS_NOOP(flags)) {
+                        c->private_users = v ? PRIVATE_USERS_SELF : PRIVATE_USERS_OFF;
+                        (void) unit_write_settingf(u, flags, name, "%s=%s", name, yes_no(v));
+                }
+
+                return 1;
+
+        } else if (streq(name, "PrivateUsersEx")) {
+                const char *s;
+                PrivateUsers t;
+
+                r = sd_bus_message_read(message, "s", &s);
+                if (r < 0)
+                        return r;
+
+                t = private_users_from_string(s);
+                if (t < 0)
+                        return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Invalid %s setting: %s", name, s);
+
+                if (!UNIT_WRITE_FLAGS_NOOP(flags)) {
+                        c->private_users = t;
+                        (void) unit_write_settingf(u, flags, name, "PrivateUsers=%s",
+                                                   private_users_to_string(c->private_users));
+                }
+
+                return 1;
+        }
+
         if (streq(name, "PrivateDevices"))
                 return bus_set_transient_bool(u, name, &c->private_devices, message, flags, error);
 
@@ -1875,9 +1927,6 @@ int bus_exec_context_set_transient_property(
         if (streq(name, "PrivateIPC"))
                 return bus_set_transient_bool(u, name, &c->private_ipc, message, flags, error);
 
-        if (streq(name, "PrivateUsers"))
-                return bus_set_transient_bool(u, name, &c->private_users, message, flags, error);
-
         if (streq(name, "NoNewPrivileges"))
                 return bus_set_transient_bool(u, name, &c->no_new_privileges, message, flags, error);
 
diff --git a/src/core/exec-invoke.c b/src/core/exec-invoke.c
index f1d25b311269e..9c2b13275e7be 100644
--- a/src/core/exec-invoke.c
+++ b/src/core/exec-invoke.c
@@ -2077,7 +2077,7 @@ static int build_pass_environment(const ExecContext *c, char ***ret) {
         return 0;
 }
 
-static int setup_private_users(uid_t ouid, gid_t ogid, uid_t uid, gid_t gid) {
+static int setup_private_users(PrivateUsers private_users, uid_t ouid, gid_t ogid, uid_t uid, gid_t gid) {
         _cleanup_free_ char *uid_map = NULL, *gid_map = NULL;
         _cleanup_close_pair_ int errno_pipe[2] = EBADF_PAIR;
         _cleanup_close_ int unshare_ready_fd = -EBADF;
@@ -2096,31 +2096,48 @@ static int setup_private_users(uid_t ouid, gid_t ogid, uid_t uid, gid_t gid) {
          * For unprivileged users (i.e. without capabilities), the root to root mapping is excluded. As such, it
          * does not need CAP_SETUID to write the single line mapping to itself. */
 
+        if (private_users == PRIVATE_USERS_OFF)
+                return 0;
+
+        if (private_users == PRIVATE_USERS_IDENTITY) {
+                uid_map = strdup("0 0 65536\n");
+                if (!uid_map)
+                        return -ENOMEM;
         /* Can only set up multiple mappings with CAP_SETUID. */
-        if (have_effective_cap(CAP_SETUID) > 0 && uid != ouid && uid_is_valid(uid))
+        } else if (have_effective_cap(CAP_SETUID) > 0 && uid != ouid && uid_is_valid(uid)) {
                 r = asprintf(&uid_map,
                              UID_FMT " " UID_FMT " 1\n"     /* Map $OUID → $OUID */
                              UID_FMT " " UID_FMT " 1\n",    /* Map $UID → $UID */
                              ouid, ouid, uid, uid);
-        else
+                if (r < 0)
+                        return -ENOMEM;
+        } else {
                 r = asprintf(&uid_map,
                              UID_FMT " " UID_FMT " 1\n",    /* Map $OUID → $OUID */
                              ouid, ouid);
-        if (r < 0)
-                return -ENOMEM;
+                if (r < 0)
+                        return -ENOMEM;
+        }
 
+        if (private_users == PRIVATE_USERS_IDENTITY) {
+                gid_map = strdup("0 0 65536\n");
+                if (!gid_map)
+                        return -ENOMEM;
         /* Can only set up multiple mappings with CAP_SETGID. */
-        if (have_effective_cap(CAP_SETGID) > 0 && gid != ogid && gid_is_valid(gid))
+        } else if (have_effective_cap(CAP_SETGID) > 0 && gid != ogid && gid_is_valid(gid)) {
                 r = asprintf(&gid_map,
                              GID_FMT " " GID_FMT " 1\n"     /* Map $OGID → $OGID */
                              GID_FMT " " GID_FMT " 1\n",    /* Map $GID → $GID */
                              ogid, ogid, gid, gid);
-        else
+                if (r < 0)
+                        return -ENOMEM;
+        } else {
                 r = asprintf(&gid_map,
                              GID_FMT " " GID_FMT " 1\n",    /* Map $OGID -> $OGID */
                              ogid, ogid);
-        if (r < 0)
-                return -ENOMEM;
+                if (r < 0)
+                        return -ENOMEM;
+        }
 
         /* Create a communication channel so that the parent can tell the child when it finished creating the user
          * namespace. */
@@ -2231,7 +2248,7 @@ static int setup_private_users(uid_t ouid, gid_t ogid, uid_t uid, gid_t gid) {
         if (r != EXIT_SUCCESS) /* If something strange happened with the child, let's consider this fatal, too */
                 return -EIO;
 
-        return 0;
+        return 1;
 }
 
 static int create_many_symlinks(const char *root, const char *source, char **symlinks) {
@@ -3834,7 +3851,7 @@ static bool exec_context_need_unprivileged_private_users(
         if (params->runtime_scope != RUNTIME_SCOPE_USER)
                 return false;
 
-        return context->private_users ||
+        return context->private_users != PRIVATE_USERS_OFF ||
                context->private_tmp != PRIVATE_TMP_OFF ||
                context->private_devices ||
                context->private_network ||
@@ -4743,18 +4760,23 @@ int exec_invoke(
                 /* If we're unprivileged, set up the user namespace first to enable use of the other namespaces.
                  * Users with CAP_SYS_ADMIN can set up user namespaces last because they will be able to
                  * set up all of the other namespaces (i.e. network, mount, UTS) without a user namespace. */
+                PrivateUsers pu = context->private_users;
+                if (pu == PRIVATE_USERS_OFF)
+                        pu = PRIVATE_USERS_SELF;
 
-                r = setup_private_users(saved_uid, saved_gid, uid, gid);
+                r = setup_private_users(pu, saved_uid, saved_gid, uid, gid);
                 /* If it was requested explicitly and we can't set it up, fail early. Otherwise, continue and let
                  * the actual requested operations fail (or silently continue). */
-                if (r < 0 && context->private_users) {
+                if (r < 0 && context->private_users != PRIVATE_USERS_OFF) {
                         *exit_status = EXIT_USER;
                         return log_exec_error_errno(context, params, r, "Failed to set up user namespacing for unprivileged user: %m");
                 }
                 if (r < 0)
                         log_exec_info_errno(context, params, r, "Failed to set up user namespacing for unprivileged user, ignoring: %m");
-                else
+                else {
+                        assert(r > 0);
                         userns_set_up = true;
+                }
         }
 
         if (exec_needs_network_namespace(context) && runtime && runtime->shared && runtime->shared->netns_storage_socket[0] >= 0) {
@@ -4867,8 +4889,8 @@ int exec_invoke(
          * case of mount namespaces being less privileged when the mount point list is copied from a
          * different user namespace). */
 
-        if (needs_sandboxing && context->private_users && !userns_set_up) {
-                r = setup_private_users(saved_uid, saved_gid, uid, gid);
+        if (needs_sandboxing && !userns_set_up) {
+                r = setup_private_users(context->private_users, saved_uid, saved_gid, uid, gid);
                 if (r < 0) {
                         *exit_status = EXIT_USER;
                         return log_exec_error_errno(context, params, r, "Failed to set up user namespacing: %m");
diff --git a/src/core/execute-serialize.c b/src/core/execute-serialize.c
index 9f287dc12b39d..b3035c0026795 100644
--- a/src/core/execute-serialize.c
+++ b/src/core/execute-serialize.c
@@ -1894,7 +1894,7 @@ static int exec_context_serialize(const ExecContext *c, FILE *f) {
         if (r < 0)
                 return r;
 
-        r = serialize_bool_elide(f, "exec-context-private-users", c->private_users);
+        r = serialize_item(f, "exec-context-private-users", private_users_to_string(c->private_users));
         if (r < 0)
                 return r;
 
@@ -2778,10 +2778,9 @@ static int exec_context_deserialize(ExecContext *c, FILE *f) {
                                 return r;
                         c->private_network = r;
                 } else if ((val = startswith(l, "exec-context-private-users="))) {
-                        r = parse_boolean(val);
-                        if (r < 0)
-                                return r;
-                        c->private_users = r;
+                        c->private_users = private_users_from_string(val);
+                        if (c->private_users < 0)
+                                return -EINVAL;
                 } else if ((val = startswith(l, "exec-context-private-ipc="))) {
                         r = parse_boolean(val);
                         if (r < 0)
diff --git a/src/core/execute.c b/src/core/execute.c
index fdbe1e3210e8d..5e0e0b3a62a58 100644
--- a/src/core/execute.c
+++ b/src/core/execute.c
@@ -1002,7 +1002,7 @@ void exec_context_dump(const ExecContext *c, FILE* f, const char *prefix) {
                 prefix, yes_no(c->protect_clock),
                 prefix, yes_no(c->protect_control_groups),
                 prefix, yes_no(c->private_network),
-                prefix, yes_no(c->private_users),
+                prefix, private_users_to_string(c->private_users),
                 prefix, protect_home_to_string(c->protect_home),
                 prefix, protect_system_to_string(c->protect_system),
                 prefix, yes_no(exec_context_get_effective_mount_apivfs(c)),
diff --git a/src/core/execute.h b/src/core/execute.h
index f9c88e9a7630f..01a196748b5c8 100644
--- a/src/core/execute.h
+++ b/src/core/execute.h
@@ -318,7 +318,7 @@ struct ExecContext {
         PrivateTmp private_tmp;
         bool private_network;
         bool private_devices;
-        bool private_users;
+        PrivateUsers private_users;
         bool private_ipc;
         bool protect_kernel_tunables;
         bool protect_kernel_modules;
diff --git a/src/core/load-fragment-gperf.gperf.in b/src/core/load-fragment-gperf.gperf.in
index b6a9c708e4921..221099a39c011 100644
--- a/src/core/load-fragment-gperf.gperf.in
+++ b/src/core/load-fragment-gperf.gperf.in
@@ -130,7 +130,7 @@
 {{type}}.IPCNamespacePath,                 config_parse_unit_path_printf,               0,                                  offsetof({{type}}, exec_context.ipc_namespace_path)
 {{type}}.LogNamespace,                     config_parse_log_namespace,                  0,                                  offsetof({{type}}, exec_context)
 {{type}}.PrivateNetwork,                   config_parse_bool,                           0,                                  offsetof({{type}}, exec_context.private_network)
-{{type}}.PrivateUsers,                     config_parse_bool,                           0,                                  offsetof({{type}}, exec_context.private_users)
+{{type}}.PrivateUsers,                     config_parse_private_users,                  0,                                  offsetof({{type}}, exec_context.private_users)
 {{type}}.PrivateMounts,                    config_parse_tristate,                       0,                                  offsetof({{type}}, exec_context.private_mounts)
 {{type}}.PrivateIPC,                       config_parse_bool,                           0,                                  offsetof({{type}}, exec_context.private_ipc)
 {{type}}.ProtectSystem,                    config_parse_protect_system,                 0,                                  offsetof({{type}}, exec_context.protect_system)
diff --git a/src/core/load-fragment.c b/src/core/load-fragment.c
index cc6597dbb7b36..7cb8648743cfd 100644
--- a/src/core/load-fragment.c
+++ b/src/core/load-fragment.c
@@ -134,6 +134,7 @@ DEFINE_CONFIG_PARSE_ENUM(config_parse_exec_keyring_mode, exec_keyring_mode, Exec
 DEFINE_CONFIG_PARSE_ENUM(config_parse_protect_proc, protect_proc, ProtectProc);
 DEFINE_CONFIG_PARSE_ENUM(config_parse_proc_subset, proc_subset, ProcSubset);
 DEFINE_CONFIG_PARSE_ENUM(config_parse_private_tmp, private_tmp, PrivateTmp);
+DEFINE_CONFIG_PARSE_ENUM(config_parse_private_users, private_users, PrivateUsers);
 DEFINE_CONFIG_PARSE_ENUM(config_parse_exec_utmp_mode, exec_utmp_mode, ExecUtmpMode);
 DEFINE_CONFIG_PARSE_ENUM(config_parse_job_mode, job_mode, JobMode);
 DEFINE_CONFIG_PARSE_ENUM(config_parse_notify_access, notify_access, NotifyAccess);
diff --git a/src/core/load-fragment.h b/src/core/load-fragment.h
index 727ee4d791859..c7301cec52e6d 100644
--- a/src/core/load-fragment.h
+++ b/src/core/load-fragment.h
@@ -112,6 +112,7 @@ CONFIG_PARSER_PROTOTYPE(config_parse_set_status);
 CONFIG_PARSER_PROTOTYPE(config_parse_namespace_path_strv);
 CONFIG_PARSER_PROTOTYPE(config_parse_temporary_filesystems);
 CONFIG_PARSER_PROTOTYPE(config_parse_private_tmp);
+CONFIG_PARSER_PROTOTYPE(config_parse_private_users);
 CONFIG_PARSER_PROTOTYPE(config_parse_cpu_quota);
 CONFIG_PARSER_PROTOTYPE(config_parse_allowed_cpuset);
 CONFIG_PARSER_PROTOTYPE(config_parse_protect_home);
diff --git a/src/core/namespace.c b/src/core/namespace.c
index ba5163cdb8c96..bd4bd6205c10e 100644
--- a/src/core/namespace.c
+++ b/src/core/namespace.c
@@ -3227,3 +3227,11 @@ static const char* const private_tmp_table[_PRIVATE_TMP_MAX] = {
 };
 
 DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN(private_tmp, PrivateTmp, PRIVATE_TMP_CONNECTED);
+
+static const char* const private_users_table[_PRIVATE_USERS_MAX] = {
+        [PRIVATE_USERS_OFF]      = "off",
+        [PRIVATE_USERS_SELF]     = "self",
+        [PRIVATE_USERS_IDENTITY] = "identity",
+};
+
+DEFINE_STRING_TABLE_LOOKUP_WITH_BOOLEAN(private_users, PrivateUsers, PRIVATE_USERS_SELF);
diff --git a/src/core/namespace.h b/src/core/namespace.h
index d961a66b4f3a3..ad62db6490a12 100644
--- a/src/core/namespace.h
+++ b/src/core/namespace.h
@@ -61,6 +61,14 @@ typedef enum PrivateTmp {
         _PRIVATE_TMP_INVALID = -EINVAL,
 } PrivateTmp;
 
+typedef enum PrivateUsers {
+        PRIVATE_USERS_OFF,
+        PRIVATE_USERS_SELF,
+        PRIVATE_USERS_IDENTITY,
+        _PRIVATE_USERS_MAX,
+        _PRIVATE_USERS_INVALID = -EINVAL,
+} PrivateUsers;
+
 struct BindMount {
         char *source;
         char *destination;
@@ -199,6 +207,9 @@ ProcSubset proc_subset_from_string(const char *s) _pure_;
 const char* private_tmp_to_string(PrivateTmp i) _const_;
 PrivateTmp private_tmp_from_string(const char *s) _pure_;
 
+const char* private_users_to_string(PrivateUsers i) _const_;
+PrivateUsers private_users_from_string(const char *s) _pure_;
+
 void bind_mount_free_many(BindMount *b, size_t n);
 int bind_mount_add(BindMount **b, size_t *n, const BindMount *item);
 
diff --git a/src/shared/bus-unit-util.c b/src/shared/bus-unit-util.c
index 6987a885d1e5f..dc92c5f609dd7 100644
--- a/src/shared/bus-unit-util.c
+++ b/src/shared/bus-unit-util.c
@@ -1038,6 +1038,7 @@ static int bus_append_execute_property(sd_bus_message *m, const char *field, con
                               "ProtectSystem",
                               "ProtectHome",
                               "PrivateTmpEx",
+                              "PrivateUsersEx",
                               "SELinuxContext",
                               "RootImage",
                               "RootVerity",
diff --git a/test/units/TEST-07-PID1.private-users.sh b/test/units/TEST-07-PID1.private-users.sh
new file mode 100755
index 0000000000000..2475b5d365d59
--- /dev/null
+++ b/test/units/TEST-07-PID1.private-users.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# shellcheck disable=SC2016
+set -eux
+set -o pipefail
+
+systemd-run -p PrivateUsers=yes --wait bash -c 'test "$(cat /proc/self/uid_map)" == "         0          0          1"'
+systemd-run -p PrivateUsers=yes --wait bash -c 'test "$(cat /proc/self/gid_map)" == "         0          0          1"'
+systemd-run -p PrivateUsersEx=self --wait bash -c 'test "$(cat /proc/self/uid_map)" == "         0          0          1"'
+systemd-run -p PrivateUsersEx=self --wait bash -c 'test "$(cat /proc/self/gid_map)" == "         0          0          1"'
+systemd-run -p PrivateUsersEx=identity --wait bash -c 'test "$(cat /proc/self/uid_map)" == "         0          0      65536"'
+systemd-run -p PrivateUsersEx=identity --wait bash -c 'test "$(cat /proc/self/gid_map)" == "         0          0      65536"'