]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
userdbctl: add some basic client-side filtering
authorLennart Poettering <lennart@poettering.net>
Wed, 23 Oct 2024 13:19:36 +0000 (15:19 +0200)
committerLennart Poettering <lennart@poettering.net>
Thu, 24 Oct 2024 08:17:23 +0000 (10:17 +0200)
This adds some basic client-side user/group filtering to "userdbctl":

1. by uid/gid min/max
2. by user "disposition" (i.e. show only regular users with "userdbctl
   user -R")
3. by fuzzy name (i.e. search by substring/levenshtein of user name,
   real name, and other identifiers of the user/group record).

In the long run we also want to support this server side, but let's
start out with doing this client-side, since many backends won't support
server-side filtering anytime soon anyway, so we need it in either case.

man/userdbctl.xml
src/shared/group-record.c
src/shared/group-record.h
src/shared/user-record.c
src/shared/user-record.h
src/userdb/userdbctl.c

index a7b438ad91b52aa267280ebf4669d09a66196917..03309f8a521aec8544675127963cdec7d3d461a1 100644 (file)
         <xi:include href="version-info.xml" xpointer="v250"/></listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><option>--fuzzy</option></term>
+        <term><option>-z</option></term>
+
+        <listitem><para>When used with the <command>user</command> or <command>group</command> command, do a
+        fuzzy string search. Any specified arguments will be matched against the user name, the real name of
+        the user record, the email address, and other descriptive strings of the user or group
+        record. Moreover, instead of precise matching, a substring match or a match allowing slight
+        deviations in spelling is applied.</para>
+
+        <xi:include href="version-info.xml" xpointer="v257"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--disposition=</option></term>
+
+        <listitem><para>When used with the <command>user</command> or <command>group</command> command,
+        filters by disposition of the record. Takes one of <literal>intrinsic</literal>,
+        <literal>system</literal>, <literal>regular</literal>, <literal>dynamic</literal>,
+        <literal>container</literal>. May be used multiple times, in which case only users matching any of
+        the specified dispositions are shown.</para>
+
+        <xi:include href="version-info.xml" xpointer="v257"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>-I</option></term>
+        <term><option>-S</option></term>
+        <term><option>-R</option></term>
+
+        <listitem><para>Shortcuts for <option>--disposition=intrinsic</option>,
+        <option>--disposition=system</option>, <option>--disposition=regular</option>,
+        respectively.</para>
+
+        <xi:include href="version-info.xml" xpointer="v257"/></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term><option>--uid-min=</option></term>
+        <term><option>--uid-max=</option></term>
+
+        <listitem><para>When used with the <command>user</command> or <command>group</command> command,
+        filters the output by UID/GID ranges. Takes numeric minimum resp. maximum UID/GID values. Shows only
+        records within the specified range. When applied to the <command>user</command> command matches
+        against UIDs, when applied to the <command>group</command> command against GIDs (despite the name of
+        the switch). If unspecified defaults to 0 (for the minimum) and 4294967294 (for the maximum), i.e. by
+        default no filtering is applied as the whole UID/GID range is covered.</para>
+
+        <xi:include href="version-info.xml" xpointer="v257"/></listitem>
+      </varlistentry>
+
       <xi:include href="standard-options.xml" xpointer="no-pager" />
       <xi:include href="standard-options.xml" xpointer="no-legend" />
       <xi:include href="standard-options.xml" xpointer="help" />
index a297272fabff87f9a5a429d39da686b553b40766..7b401bf06449e0d34b16a00dd2332fa840b251a6 100644 (file)
@@ -326,3 +326,28 @@ int group_record_clone(GroupRecord *h, UserRecordLoadFlags flags, GroupRecord **
         *ret = TAKE_PTR(c);
         return 0;
 }
+
+int group_record_match(GroupRecord *h, const UserDBMatch *match) {
+        assert(h);
+        assert(match);
+
+        if (h->gid < match->gid_min || h->gid > match->gid_max)
+                return false;
+
+        if (!FLAGS_SET(match->disposition_mask, UINT64_C(1) << group_record_disposition(h)))
+                return false;
+
+        if (!strv_isempty(match->fuzzy_names)) {
+                const char* names[] = {
+                        h->group_name,
+                        group_record_group_name_and_realm(h),
+                        h->description,
+                };
+
+                if (!user_name_fuzzy_match(names, ELEMENTSOF(names), match->fuzzy_names))
+                        return false;
+        }
+
+        return true;
+
+}
index 054849b40984ccf963b29b7ec4343063c5080c15..a2cef81c8a2dd44c62d1d8b6ff3558cc358fe68d 100644 (file)
@@ -43,5 +43,7 @@ int group_record_load(GroupRecord *h, sd_json_variant *v, UserRecordLoadFlags fl
 int group_record_build(GroupRecord **ret, ...);
 int group_record_clone(GroupRecord *g, UserRecordLoadFlags flags, GroupRecord **ret);
 
+int group_record_match(GroupRecord *h, const UserDBMatch *match);
+
 const char* group_record_group_name_and_realm(GroupRecord *h);
 UserDisposition group_record_disposition(GroupRecord *h);
index f14a38e03bf2558e30d1813990f6d53d285c7352..12447a93379c708f713d00e2e05616cbe0f8139f 100644 (file)
@@ -2401,6 +2401,72 @@ int suitable_blob_filename(const char *name) {
                name[0] != '.';
 }
 
+bool user_name_fuzzy_match(const char *names[], size_t n_names, char **matches) {
+        assert(names || n_names == 0);
+
+        /* Checks if any of the user record strings in the names[] array matches any of the search strings in
+         * the matches** strv fuzzily. */
+
+        FOREACH_ARRAY(n, names, n_names) {
+                if (!*n)
+                        continue;
+
+                _cleanup_free_ char *lcn = strdup(*n);
+                if (!lcn)
+                        return -ENOMEM;
+
+                ascii_strlower(lcn);
+
+                STRV_FOREACH(i, matches) {
+                        _cleanup_free_ char *lc = strdup(*i);
+                        if (!lc)
+                                return -ENOMEM;
+
+                        ascii_strlower(lc);
+
+                        /* First do substring check */
+                        if (strstr(lcn, lc))
+                                return true;
+
+                        /* Then do some fuzzy string comparison (but only if the needle is non-trivially long) */
+                        if (strlen(lc) >= 5 && strlevenshtein(lcn, lc) < 3)
+                                return true;
+                }
+        }
+
+        return false;
+}
+
+int user_record_match(UserRecord *u, const UserDBMatch *match) {
+        assert(u);
+        assert(match);
+
+        if (u->uid < match->uid_min || u->uid > match->uid_max)
+                return false;
+
+        if (!FLAGS_SET(match->disposition_mask, UINT64_C(1) << user_record_disposition(u)))
+                return false;
+
+        if (!strv_isempty(match->fuzzy_names)) {
+
+                /* Note this array of names is sparse, i.e. various entries listed in it will be
+                 * NULL. Because of that we are not using a NULL terminated strv here, but a regular
+                 * array. */
+                const char* names[] = {
+                        u->user_name,
+                        user_record_user_name_and_realm(u),
+                        u->real_name,
+                        u->email_address,
+                        u->cifs_user_name,
+                };
+
+                if (!user_name_fuzzy_match(names, ELEMENTSOF(names), match->fuzzy_names))
+                        return false;
+        }
+
+        return true;
+}
+
 static const char* const user_storage_table[_USER_STORAGE_MAX] = {
         [USER_CLASSIC]   = "classic",
         [USER_LUKS]      = "luks",
index 2a0e92d69ad2729521ccb33d753d0541456938a1..0443820890ca79c2e3b61977a441cdff08b73710 100644 (file)
@@ -462,6 +462,24 @@ int user_group_record_mangle(sd_json_variant *v, UserRecordLoadFlags load_flags,
 #define BLOB_DIR_MAX_SIZE (UINT64_C(64) * U64_MB)
 int suitable_blob_filename(const char *name);
 
+typedef struct UserDBMatch {
+        char **fuzzy_names;
+        uint64_t disposition_mask;
+        union {
+                uid_t uid_min;
+                gid_t gid_min;
+        };
+        union {
+                uid_t uid_max;
+                gid_t gid_max;
+        };
+} UserDBMatch;
+
+#define USER_DISPOSITION_MASK_MAX ((UINT64_C(1) << _USER_DISPOSITION_MAX) - UINT64_C(1))
+
+bool user_name_fuzzy_match(const char *names[], size_t n_names, char **matches);
+int user_record_match(UserRecord *u, const UserDBMatch *match);
+
 const char* user_storage_to_string(UserStorage t) _const_;
 UserStorage user_storage_from_string(const char *s) _pure_;
 
index 5997f9604f0f400daf3a454ae0a2ff79a21d5b6e..579143aef6dc68bc57855b90c30984a66276dbe8 100644 (file)
@@ -37,6 +37,10 @@ static char** arg_services = NULL;
 static UserDBFlags arg_userdb_flags = 0;
 static sd_json_format_flags_t arg_json_format_flags = SD_JSON_FORMAT_OFF;
 static bool arg_chain = false;
+static uint64_t arg_disposition_mask = UINT64_MAX;
+static uid_t arg_uid_min = 0;
+static uid_t arg_uid_max = UID_INVALID-1;
+static bool arg_fuzzy = false;
 
 STATIC_DESTRUCTOR_REGISTER(arg_services, strv_freep);
 
@@ -176,6 +180,9 @@ static int table_add_uid_boundaries(Table *table, const UIDRange *p) {
         FOREACH_ELEMENT(i, uid_range_table) {
                 _cleanup_free_ char *name = NULL, *comment = NULL;
 
+                if (!FLAGS_SET(arg_disposition_mask, UINT64_C(1) << i->disposition))
+                        continue;
+
                 if (!uid_range_covers(p, i->first, i->last - i->first + 1))
                         continue;
 
@@ -346,7 +353,7 @@ static int display_user(int argc, char *argv[], void *userdata) {
         int ret = 0, r;
 
         if (arg_output < 0)
-                arg_output = argc > 1 ? OUTPUT_FRIENDLY : OUTPUT_TABLE;
+                arg_output = argc > 1 && !arg_fuzzy ? OUTPUT_FRIENDLY : OUTPUT_TABLE;
 
         if (arg_output == OUTPUT_TABLE) {
                 table = table_new(" ", "name", "disposition", "uid", "gid", "realname", "home", "shell", "order");
@@ -360,7 +367,13 @@ static int display_user(int argc, char *argv[], void *userdata) {
                 (void) table_set_display(table, (size_t) 0, (size_t) 1, (size_t) 2, (size_t) 3, (size_t) 4, (size_t) 5, (size_t) 6, (size_t) 7);
         }
 
-        if (argc > 1)
+        UserDBMatch match = {
+                .disposition_mask = arg_disposition_mask,
+                .uid_min = arg_uid_min,
+                .uid_max = arg_uid_max,
+        };
+
+        if (argc > 1 && !arg_fuzzy)
                 STRV_FOREACH(i, argv + 1) {
                         _cleanup_(user_record_unrefp) UserRecord *ur = NULL;
                         uid_t uid;
@@ -377,8 +390,10 @@ static int display_user(int argc, char *argv[], void *userdata) {
                                 else
                                         log_error_errno(r, "Failed to find user %s: %m", *i);
 
-                                if (ret >= 0)
-                                        ret = r;
+                                RET_GATHER(ret, r);
+                        } else if (!user_record_match(ur, &match)) {
+                                log_error("User '%s' does not match filter.", *i);
+                                RET_GATHER(ret, -ENOEXEC);
                         } else {
                                 if (draw_separator && arg_output == OUTPUT_FRIENDLY)
                                         putchar('\n');
@@ -392,6 +407,15 @@ static int display_user(int argc, char *argv[], void *userdata) {
                 }
         else {
                 _cleanup_(userdb_iterator_freep) UserDBIterator *iterator = NULL;
+                _cleanup_strv_free_ char **names = NULL;
+
+                if (argc > 1) {
+                        names = strv_copy(argv + 1);
+                        if (!names)
+                                return log_oom();
+
+                        match.fuzzy_names = names;
+                }
 
                 r = userdb_all(arg_userdb_flags, &iterator);
                 if (r == -ENOLINK) /* ENOLINK → Didn't find answer without Varlink, and didn't try Varlink because was configured to off. */
@@ -412,6 +436,9 @@ static int display_user(int argc, char *argv[], void *userdata) {
                                 if (r < 0)
                                         return log_error_errno(r, "Failed acquire next user: %m");
 
+                                if (!user_record_match(ur, &match))
+                                        continue;
+
                                 if (draw_separator && arg_output == OUTPUT_FRIENDLY)
                                         putchar('\n');
 
@@ -650,7 +677,7 @@ static int display_group(int argc, char *argv[], void *userdata) {
         int ret = 0, r;
 
         if (arg_output < 0)
-                arg_output = argc > 1 ? OUTPUT_FRIENDLY : OUTPUT_TABLE;
+                arg_output = argc > 1 && !arg_fuzzy ? OUTPUT_FRIENDLY : OUTPUT_TABLE;
 
         if (arg_output == OUTPUT_TABLE) {
                 table = table_new(" ", "name", "disposition", "gid", "description", "order");
@@ -663,7 +690,13 @@ static int display_group(int argc, char *argv[], void *userdata) {
                 (void) table_set_display(table, (size_t) 0, (size_t) 1, (size_t) 2, (size_t) 3, (size_t) 4);
         }
 
-        if (argc > 1)
+        UserDBMatch match = {
+                .disposition_mask = arg_disposition_mask,
+                .gid_min = arg_uid_min,
+                .gid_max = arg_uid_max,
+        };
+
+        if (argc > 1 && !arg_fuzzy)
                 STRV_FOREACH(i, argv + 1) {
                         _cleanup_(group_record_unrefp) GroupRecord *gr = NULL;
                         gid_t gid;
@@ -680,8 +713,10 @@ static int display_group(int argc, char *argv[], void *userdata) {
                                 else
                                         log_error_errno(r, "Failed to find group %s: %m", *i);
 
-                                if (ret >= 0)
-                                        ret = r;
+                                RET_GATHER(ret, r);
+                        } else if (!group_record_match(gr, &match)) {
+                                log_error("Group '%s' does not match filter.", *i);
+                                RET_GATHER(ret, -ENOEXEC);
                         } else {
                                 if (draw_separator && arg_output == OUTPUT_FRIENDLY)
                                         putchar('\n');
@@ -695,6 +730,15 @@ static int display_group(int argc, char *argv[], void *userdata) {
                 }
         else {
                 _cleanup_(userdb_iterator_freep) UserDBIterator *iterator = NULL;
+                _cleanup_strv_free_ char **names = NULL;
+
+                if (argc > 1) {
+                        names = strv_copy(argv + 1);
+                        if (!names)
+                                return log_oom();
+
+                        match.fuzzy_names = names;
+                }
 
                 r = groupdb_all(arg_userdb_flags, &iterator);
                 if (r == -ENOLINK)
@@ -715,6 +759,9 @@ static int display_group(int argc, char *argv[], void *userdata) {
                                 if (r < 0)
                                         return log_error_errno(r, "Failed acquire next group: %m");
 
+                                if (!group_record_match(gr, &match))
+                                        continue;
+
                                 if (draw_separator && arg_output == OUTPUT_FRIENDLY)
                                         putchar('\n');
 
@@ -1090,6 +1137,13 @@ static int help(int argc, char *argv[], void *userdata) {
                "     --multiplexer=BOOL      Control whether to use the multiplexer\n"
                "     --json=pretty|short     JSON output mode\n"
                "     --chain                 Chain another command\n"
+               "     --uid-min=ID            Filter by minimum UID/GID (default 0)\n"
+               "     --uid-max=ID            Filter by maximum UID/GID (default 4294967294)\n"
+               "  -z --fuzzy                 Do a fuzzy name search\n"
+               "     --disposition=VALUE     Filter by disposition\n"
+               "  -I                         Equivalent to --disposition=intrinsic\n"
+               "  -S                         Equivalent to --disposition=system\n"
+               "  -R                         Equivalent to --disposition=regular\n"
                "\nSee the %s for details.\n",
                program_invocation_short_name,
                ansi_highlight(),
@@ -1113,6 +1167,9 @@ static int parse_argv(int argc, char *argv[]) {
                 ARG_MULTIPLEXER,
                 ARG_JSON,
                 ARG_CHAIN,
+                ARG_UID_MIN,
+                ARG_UID_MAX,
+                ARG_DISPOSITION,
         };
 
         static const struct option options[] = {
@@ -1129,6 +1186,10 @@ static int parse_argv(int argc, char *argv[]) {
                 { "multiplexer",  required_argument, NULL, ARG_MULTIPLEXER  },
                 { "json",         required_argument, NULL, ARG_JSON         },
                 { "chain",        no_argument,       NULL, ARG_CHAIN        },
+                { "uid-min",      required_argument, NULL, ARG_UID_MIN      },
+                { "uid-max",      required_argument, NULL, ARG_UID_MAX      },
+                { "fuzzy",        required_argument, NULL, 'z'              },
+                { "disposition",  required_argument, NULL, ARG_DISPOSITION  },
                 {}
         };
 
@@ -1159,7 +1220,7 @@ static int parse_argv(int argc, char *argv[]) {
                 int c;
 
                 c = getopt_long(argc, argv,
-                                arg_chain ? "+hjs:N" : "hjs:N", /* When --chain was used disable parsing of further switches */
+                                arg_chain ? "+hjs:NISRz" : "hjs:NISRz", /* When --chain was used disable parsing of further switches */
                                 options, NULL);
                 if (c < 0)
                         break;
@@ -1275,6 +1336,55 @@ static int parse_argv(int argc, char *argv[]) {
                         arg_chain = true;
                         break;
 
+                case ARG_DISPOSITION: {
+                        UserDisposition d = user_disposition_from_string(optarg);
+                        if (d < 0)
+                                return log_error_errno(d, "Unknown user disposition: %s", optarg);
+
+                        if (arg_disposition_mask == UINT64_MAX)
+                                arg_disposition_mask = 0;
+
+                        arg_disposition_mask |= UINT64_C(1) << d;
+                        break;
+                }
+
+                case 'I':
+                        if (arg_disposition_mask == UINT64_MAX)
+                                arg_disposition_mask = 0;
+
+                        arg_disposition_mask |= UINT64_C(1) << USER_INTRINSIC;
+                        break;
+
+                case 'S':
+                        if (arg_disposition_mask == UINT64_MAX)
+                                arg_disposition_mask = 0;
+
+                        arg_disposition_mask |= UINT64_C(1) << USER_SYSTEM;
+                        break;
+
+                case 'R':
+                        if (arg_disposition_mask == UINT64_MAX)
+                                arg_disposition_mask = 0;
+
+                        arg_disposition_mask |= UINT64_C(1) << USER_REGULAR;
+                        break;
+
+                case ARG_UID_MIN:
+                        r = parse_uid(optarg, &arg_uid_min);
+                        if (r < 0)
+                                return log_error_errno(r, "Failed to parse --uid-min= value: %s", optarg);
+                        break;
+
+                case ARG_UID_MAX:
+                        r = parse_uid(optarg, &arg_uid_max);
+                        if (r < 0)
+                                return log_error_errno(r, "Failed to parse --uid-max= value: %s", optarg);
+                        break;
+
+                case 'z':
+                        arg_fuzzy = true;
+                        break;
+
                 case '?':
                         return -EINVAL;
 
@@ -1283,6 +1393,13 @@ static int parse_argv(int argc, char *argv[]) {
                 }
         }
 
+        if (arg_uid_min > arg_uid_max)
+                return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Minimum UID/GID " UID_FMT " is above maximum UID/GID " UID_FMT ", refusing.", arg_uid_min, arg_uid_max);
+
+        /* If not mask was specified, use the all bits on mask */
+        if (arg_disposition_mask == UINT64_MAX)
+                arg_disposition_mask = USER_DISPOSITION_MASK_MAX;
+
         return 1;
 }