userdbctl: add some basic client-side filtering

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.
This commit is contained in:
Lennart Poettering 2024-10-23 15:19:36 +02:00
parent e7c567cc78
commit ad5de3222f
6 changed files with 288 additions and 9 deletions

View File

@ -174,6 +174,57 @@
<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" />

View 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;
}

View 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);

View 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",

View 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_;

View 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;
}