homectl: Add flags to edit blob directories
This makes it possible to edit blob directories using homectl. The following syntax is available: * `--blob-directory=/path/somewhere`: Replaces the entire blob directory with the contents of /path/somewhere * `--blob-directory=foobar=/path/somewhere`: Replaces just the file foobar in the blob directory with the contents of /path/somewhere * `--blob-directory=foobar=`: Deletes the file foobar from the blob directory * `--blob-directory=`: Resets all previous flags * `--avatar=`, etc: Shortcuts for `--blob-directory=FILENAME=` for the known files in the blob directory
This commit is contained in:
committed by
Luca Boccassi
parent
a4d72746c7
commit
25c89b8977
@@ -507,6 +507,40 @@
|
||||
<xi:include href="version-info.xml" xpointer="v247"/></listitem>
|
||||
</varlistentry>
|
||||
|
||||
<varlistentry>
|
||||
<term><option>--blob=</option><replaceable>PATH</replaceable></term>
|
||||
<term><option>-b</option> <replaceable>PATH</replaceable></term>
|
||||
<term><option>--blob=</option><replaceable>FILENAME</replaceable>=<replaceable>PATH</replaceable></term>
|
||||
<term><option>-b</option> <replaceable>FILENAME</replaceable>=<replaceable>PATH</replaceable></term>
|
||||
|
||||
<listitem><para>Accepts either a directory path, or a file name followed by a file path. If just a
|
||||
directory path is specified, then the user's entire blob directory is replaced the specified path.
|
||||
Note that this replacement is performed before per-file manipulations are applied, which means these per-file
|
||||
manipulations will be applied on top of the specified directory. If a filename and file path are specified, then
|
||||
the single specified blob file will be overwritten with the specified path. If completely blank, the entire blob
|
||||
directory is emptied out (which also resets all previous blob-related flags up to this point). If a filename is
|
||||
specified but the corresponding path is blank, that single file will be deleted from the blob directory. All changes
|
||||
are performed in temporary copies of the specified files in directories, which means that the originals specified on
|
||||
the command line are not modified. See <ulink url="https://systemd.io/USER_RECORD_BLOB_DIRS">User Record Blob Directories</ulink>
|
||||
for more information about blob directories.</para>
|
||||
|
||||
<xi:include href="version-info.xml" xpointer="v256"/></listitem>
|
||||
</varlistentry>
|
||||
|
||||
<varlistentry>
|
||||
<term><option>--avatar=</option><replaceable>PATH</replaceable></term>
|
||||
<term><option>--login-background=</option><replaceable>PATH</replaceable></term>
|
||||
|
||||
<listitem><para>Accept a file path. If set, the specifified file is used to overwrite the
|
||||
corresponding file in the user's blob directory. If blank, the corresponding file is deleted
|
||||
from the blob directory. Essentially, these options are shortcuts to
|
||||
<option>--blob=</option><replaceable>FILENAME</replaceable>=<replaceable>PATH</replaceable>
|
||||
for the known filenames defined in
|
||||
<ulink url="https://systemd.io/USER_RECORD_BLOB_DIRS">User Record Blob Directories</ulink>.</para>
|
||||
|
||||
<xi:include href="version-info.xml" xpointer="v256"/></listitem>
|
||||
</varlistentry>
|
||||
|
||||
<varlistentry>
|
||||
<term><option>--locked=</option><replaceable>BOOLEAN</replaceable></term>
|
||||
|
||||
|
||||
@@ -106,7 +106,10 @@ _homectl() {
|
||||
--cifs-service
|
||||
--stop-delay
|
||||
--kill-processes
|
||||
--auto-login'
|
||||
--auto-login
|
||||
-b --blob
|
||||
--avatar
|
||||
--login-background'
|
||||
)
|
||||
|
||||
if __contains_word "$prev" ${OPTS[ARG]}; then
|
||||
@@ -117,7 +120,7 @@ _homectl() {
|
||||
--machine|-M)
|
||||
comps=$( __get_machines )
|
||||
;;
|
||||
--identity|--image-path)
|
||||
--identity|--image-path|--avatar|--login-background)
|
||||
comps=$(compgen -A file -- "$cur" )
|
||||
compopt -o filenames
|
||||
;;
|
||||
@@ -130,7 +133,7 @@ _homectl() {
|
||||
--locked|--enforce-password-policy|--password-change-now|--luks-discard|--luks-offline-discard|--nosuid|--nodev|--noexec|--kill-processes|--auto-login)
|
||||
comps='yes no'
|
||||
;;
|
||||
-d|--home-dir|--skel)
|
||||
-d|--home-dir|--skel|-b|--blob)
|
||||
comps=$(compgen -A directory -- "$cur" )
|
||||
compopt -o dirnames
|
||||
;;
|
||||
|
||||
+250
-6
@@ -12,7 +12,9 @@
|
||||
#include "cap-list.h"
|
||||
#include "capability-util.h"
|
||||
#include "cgroup-util.h"
|
||||
#include "copy.h"
|
||||
#include "creds-util.h"
|
||||
#include "dirent-util.h"
|
||||
#include "dns-domain.h"
|
||||
#include "env-util.h"
|
||||
#include "fd-util.h"
|
||||
@@ -20,6 +22,7 @@
|
||||
#include "format-table.h"
|
||||
#include "fs-util.h"
|
||||
#include "glyph-util.h"
|
||||
#include "hashmap.h"
|
||||
#include "home-util.h"
|
||||
#include "homectl-fido2.h"
|
||||
#include "homectl-pkcs11.h"
|
||||
@@ -40,8 +43,10 @@
|
||||
#include "process-util.h"
|
||||
#include "recurse-dir.h"
|
||||
#include "rlimit-util.h"
|
||||
#include "rm-rf.h"
|
||||
#include "spawn-polkit-agent.h"
|
||||
#include "terminal-util.h"
|
||||
#include "tmpfile-util.h"
|
||||
#include "uid-classification.h"
|
||||
#include "user-record.h"
|
||||
#include "user-record-password-quality.h"
|
||||
@@ -85,6 +90,9 @@ static enum {
|
||||
static uint64_t arg_capability_bounding_set = UINT64_MAX;
|
||||
static uint64_t arg_capability_ambient_set = UINT64_MAX;
|
||||
static bool arg_prompt_new_user = false;
|
||||
static char *arg_blob_dir = NULL;
|
||||
static bool arg_blob_clear = false;
|
||||
static Hashmap *arg_blob_files = NULL;
|
||||
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_identity_extra, json_variant_unrefp);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_identity_extra_this_machine, json_variant_unrefp);
|
||||
@@ -94,6 +102,8 @@ STATIC_DESTRUCTOR_REGISTER(arg_identity_filter, strv_freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_identity_filter_rlimits, strv_freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_pkcs11_token_uri, strv_freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_fido2_device, strv_freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_blob_dir, freep);
|
||||
STATIC_DESTRUCTOR_REGISTER(arg_blob_files, hashmap_freep);
|
||||
|
||||
static const BusLocator *bus_mgr;
|
||||
|
||||
@@ -107,7 +117,10 @@ static bool identity_properties_specified(void) {
|
||||
!strv_isempty(arg_identity_filter) ||
|
||||
!strv_isempty(arg_identity_filter_rlimits) ||
|
||||
!strv_isempty(arg_pkcs11_token_uri) ||
|
||||
!strv_isempty(arg_fido2_device);
|
||||
!strv_isempty(arg_fido2_device) ||
|
||||
arg_blob_dir ||
|
||||
arg_blob_clear ||
|
||||
!hashmap_isempty(arg_blob_files);
|
||||
}
|
||||
|
||||
static int acquire_bus(sd_bus **bus) {
|
||||
@@ -1266,9 +1279,132 @@ static int acquire_new_password(
|
||||
}
|
||||
}
|
||||
|
||||
static int acquire_merged_blob_dir(UserRecord *hr, bool existing, Hashmap **ret) {
|
||||
_cleanup_free_ char *sys_blob_path = NULL;
|
||||
_cleanup_hashmap_free_ Hashmap *blobs = NULL;
|
||||
_cleanup_closedir_ DIR *d = NULL;
|
||||
const char *src_blob_path, *filename;
|
||||
void *fd_ptr;
|
||||
int r;
|
||||
|
||||
assert(ret);
|
||||
|
||||
HASHMAP_FOREACH_KEY(fd_ptr, filename, arg_blob_files) {
|
||||
_cleanup_free_ char *filename_dup = NULL;
|
||||
_cleanup_close_ int fd_dup = -EBADF;
|
||||
|
||||
filename_dup = strdup(filename);
|
||||
if (!filename_dup)
|
||||
return log_oom();
|
||||
|
||||
if (PTR_TO_FD(fd_ptr) != -EBADF) {
|
||||
fd_dup = fcntl(PTR_TO_FD(fd_ptr), F_DUPFD_CLOEXEC, 3);
|
||||
if (fd_dup < 0)
|
||||
return log_error_errno(errno, "Failed to duplicate fd of %s: %m", filename);
|
||||
}
|
||||
|
||||
r = hashmap_ensure_put(&blobs, &blob_fd_hash_ops, filename_dup, FD_TO_PTR(fd_dup));
|
||||
if (r < 0)
|
||||
return r;
|
||||
TAKE_PTR(filename_dup); /* Ownership transferred to hashmap */
|
||||
TAKE_FD(fd_dup);
|
||||
}
|
||||
|
||||
if (arg_blob_dir)
|
||||
src_blob_path = arg_blob_dir;
|
||||
else if (existing && !arg_blob_clear) {
|
||||
if (hr->blob_directory)
|
||||
src_blob_path = hr->blob_directory;
|
||||
else {
|
||||
/* This isn't technically a correct thing to do for generic user records,
|
||||
* so anyone looking at this code for reference shouldn't replicate it.
|
||||
* However, since homectl is tied to homed, this is OK. This adds robustness
|
||||
* for situations where the user record is coming directly from the CLI and
|
||||
* thus doesn't have a blobDirectory set */
|
||||
|
||||
sys_blob_path = path_join(home_system_blob_dir(), hr->user_name);
|
||||
if (!sys_blob_path)
|
||||
return log_oom();
|
||||
|
||||
src_blob_path = sys_blob_path;
|
||||
}
|
||||
} else
|
||||
goto nodir; /* Shortcut: no dir to merge with, so just return copy of arg_blob_files */
|
||||
|
||||
d = opendir(src_blob_path);
|
||||
if (!d)
|
||||
return log_error_errno(errno, "Failed to open %s: %m", src_blob_path);
|
||||
|
||||
FOREACH_DIRENT_ALL(de, d, return log_error_errno(errno, "Failed to read %s: %m", src_blob_path)) {
|
||||
_cleanup_free_ char *name = NULL;
|
||||
_cleanup_close_ int fd = -EBADF;
|
||||
|
||||
if (dot_or_dot_dot(de->d_name))
|
||||
continue;
|
||||
|
||||
if (hashmap_contains(blobs, de->d_name))
|
||||
continue; /* arg_blob_files should override the base dir */
|
||||
|
||||
if (!suitable_blob_filename(de->d_name)) {
|
||||
log_warning("File %s in blob directory %s has an invalid filename. Skipping.", de->d_name, src_blob_path);
|
||||
continue;
|
||||
}
|
||||
|
||||
name = strdup(de->d_name);
|
||||
if (!name)
|
||||
return log_oom();
|
||||
|
||||
fd = openat(dirfd(d), de->d_name, O_RDONLY|O_CLOEXEC|O_NOCTTY);
|
||||
if (fd < 0)
|
||||
return log_error_errno(errno, "Failed to open %s in %s: %m", de->d_name, src_blob_path);
|
||||
|
||||
r = fd_verify_regular(fd);
|
||||
if (r < 0) {
|
||||
log_warning_errno(r, "Entry %s in blob directory %s is not a regular file. Skipping.", de->d_name, src_blob_path);
|
||||
continue;
|
||||
}
|
||||
|
||||
r = hashmap_ensure_put(&blobs, &blob_fd_hash_ops, name, FD_TO_PTR(fd));
|
||||
if (r < 0)
|
||||
return r;
|
||||
TAKE_PTR(name); /* Ownership transferred to hashmap */
|
||||
TAKE_FD(fd);
|
||||
}
|
||||
|
||||
nodir:
|
||||
*ret = TAKE_PTR(blobs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int bus_message_append_blobs(sd_bus_message *m, Hashmap *blobs) {
|
||||
const char *filename;
|
||||
void *fd_ptr;
|
||||
int r;
|
||||
|
||||
assert(m);
|
||||
|
||||
r = sd_bus_message_open_container(m, 'a', "{sh}");
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
HASHMAP_FOREACH_KEY(fd_ptr, filename, blobs) {
|
||||
int fd = PTR_TO_FD(fd_ptr);
|
||||
|
||||
if (fd == -EBADF) /* File marked for deletion */
|
||||
continue;
|
||||
|
||||
r = sd_bus_message_append(m, "{sh}", filename, fd);
|
||||
if (r < 0)
|
||||
return r;
|
||||
}
|
||||
|
||||
return sd_bus_message_close_container(m);
|
||||
}
|
||||
|
||||
static int create_home_common(JsonVariant *input) {
|
||||
_cleanup_(sd_bus_flush_close_unrefp) sd_bus *bus = NULL;
|
||||
_cleanup_(user_record_unrefp) UserRecord *hr = NULL;
|
||||
_cleanup_hashmap_free_ Hashmap *blobs = NULL;
|
||||
int r;
|
||||
|
||||
r = acquire_bus(&bus);
|
||||
@@ -1281,6 +1417,10 @@ static int create_home_common(JsonVariant *input) {
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
r = acquire_merged_blob_dir(hr, false, &blobs);
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
/* If the JSON record carries no plain text password (besides the recovery key), then let's query it
|
||||
* manually. */
|
||||
if (strv_length(hr->password) <= arg_recovery_key) {
|
||||
@@ -1327,7 +1467,7 @@ static int create_home_common(JsonVariant *input) {
|
||||
if (r < 0)
|
||||
return log_error_errno(r, "Failed to format user record: %m");
|
||||
|
||||
r = bus_message_new_method_call(bus, &m, bus_mgr, "CreateHome");
|
||||
r = bus_message_new_method_call(bus, &m, bus_mgr, "CreateHomeEx");
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
@@ -1337,6 +1477,14 @@ static int create_home_common(JsonVariant *input) {
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
r = bus_message_append_blobs(m, blobs);
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
r = sd_bus_message_append(m, "t", 0);
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
r = sd_bus_call(bus, m, HOME_SLOW_BUS_CALL_TIMEOUT_USEC, &error, NULL);
|
||||
if (r < 0) {
|
||||
if (sd_bus_error_has_name(&error, BUS_ERROR_LOW_PASSWORD_QUALITY)) {
|
||||
@@ -1492,7 +1640,7 @@ static int acquire_updated_home_record(
|
||||
|
||||
reply = sd_bus_message_unref(reply);
|
||||
|
||||
r = json_variant_filter(&json, STRV_MAKE("binding", "status", "signature"));
|
||||
r = json_variant_filter(&json, STRV_MAKE("binding", "status", "signature", "blobManifest"));
|
||||
if (r < 0)
|
||||
return log_error_errno(r, "Failed to strip binding and status from record to update: %m");
|
||||
}
|
||||
@@ -1562,6 +1710,7 @@ static int update_home(int argc, char *argv[], void *userdata) {
|
||||
_cleanup_(sd_bus_flush_close_unrefp) sd_bus *bus = NULL;
|
||||
_cleanup_(user_record_unrefp) UserRecord *hr = NULL, *secret = NULL;
|
||||
_cleanup_free_ char *buffer = NULL;
|
||||
_cleanup_hashmap_free_ Hashmap *blobs = NULL;
|
||||
const char *username;
|
||||
int r;
|
||||
|
||||
@@ -1595,6 +1744,10 @@ static int update_home(int argc, char *argv[], void *userdata) {
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
r = acquire_merged_blob_dir(hr, true, &blobs);
|
||||
if (r < 0)
|
||||
return r;
|
||||
|
||||
/* If we do multiple operations, let's output things more verbosely, since otherwise the repeated
|
||||
* authentication might be confusing. */
|
||||
|
||||
@@ -1606,7 +1759,7 @@ static int update_home(int argc, char *argv[], void *userdata) {
|
||||
_cleanup_(sd_bus_message_unrefp) sd_bus_message *m = NULL;
|
||||
_cleanup_free_ char *formatted = NULL;
|
||||
|
||||
r = bus_message_new_method_call(bus, &m, bus_mgr, "UpdateHome");
|
||||
r = bus_message_new_method_call(bus, &m, bus_mgr, "UpdateHomeEx");
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
@@ -1620,6 +1773,14 @@ static int update_home(int argc, char *argv[], void *userdata) {
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
r = bus_message_append_blobs(m, blobs);
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
r = sd_bus_message_append(m, "t", 0);
|
||||
if (r < 0)
|
||||
return bus_log_create_error(r);
|
||||
|
||||
r = sd_bus_call(bus, m, HOME_SLOW_BUS_CALL_TIMEOUT_USEC, &error, NULL);
|
||||
if (r < 0) {
|
||||
if (arg_and_change_password &&
|
||||
@@ -2451,7 +2612,13 @@ static int help(int argc, char *argv[], void *userdata) {
|
||||
" Whether to require user verification to unlock\n"
|
||||
" the account\n"
|
||||
" --recovery-key=BOOL Add a recovery key\n"
|
||||
"\n%4$sAccount Management User Record Properties:%5$s\n"
|
||||
"\n%4$sBlob Directory User Record Properties:%5$s\n"
|
||||
" -b --blob=[FILENAME=]PATH\n"
|
||||
" Path to a replacement blob directory, or replace\n"
|
||||
" an individual files in the blob directory.\n"
|
||||
" --avatar=PATH Path to user avatar picture\n"
|
||||
" --login-background=PATH Path to user login background picture\n"
|
||||
"\n%4$sAccount Management User Record Properties:%5$s\n"
|
||||
" --locked=BOOL Set locked account state\n"
|
||||
" --not-before=TIMESTAMP Do not allow logins before\n"
|
||||
" --not-after=TIMESTAMP Do not allow logins after\n"
|
||||
@@ -2626,6 +2793,8 @@ static int parse_argv(int argc, char *argv[]) {
|
||||
ARG_CAPABILITY_BOUNDING_SET,
|
||||
ARG_CAPABILITY_AMBIENT_SET,
|
||||
ARG_PROMPT_NEW_USER,
|
||||
ARG_AVATAR,
|
||||
ARG_LOGIN_BACKGROUND,
|
||||
};
|
||||
|
||||
static const struct option options[] = {
|
||||
@@ -2719,6 +2888,9 @@ static int parse_argv(int argc, char *argv[]) {
|
||||
{ "capability-bounding-set", required_argument, NULL, ARG_CAPABILITY_BOUNDING_SET },
|
||||
{ "capability-ambient-set", required_argument, NULL, ARG_CAPABILITY_AMBIENT_SET },
|
||||
{ "prompt-new-user", no_argument, NULL, ARG_PROMPT_NEW_USER },
|
||||
{ "blob", required_argument, NULL, 'b' },
|
||||
{ "avatar", required_argument, NULL, ARG_AVATAR },
|
||||
{ "login-background", required_argument, NULL, ARG_LOGIN_BACKGROUND },
|
||||
{}
|
||||
};
|
||||
|
||||
@@ -2730,7 +2902,7 @@ static int parse_argv(int argc, char *argv[]) {
|
||||
for (;;) {
|
||||
int c;
|
||||
|
||||
c = getopt_long(argc, argv, "hH:M:I:c:d:u:k:s:e:G:jPE", options, NULL);
|
||||
c = getopt_long(argc, argv, "hH:M:I:c:d:u:G:k:s:e:b:jPE", options, NULL);
|
||||
if (c < 0)
|
||||
break;
|
||||
|
||||
@@ -4027,6 +4199,78 @@ static int parse_argv(int argc, char *argv[]) {
|
||||
arg_prompt_new_user = true;
|
||||
break;
|
||||
|
||||
case 'b':
|
||||
case ARG_AVATAR:
|
||||
case ARG_LOGIN_BACKGROUND: {
|
||||
_cleanup_close_ int fd = -EBADF;
|
||||
_cleanup_free_ char *path = NULL, *filename = NULL;
|
||||
|
||||
if (c == 'b') {
|
||||
char *eq;
|
||||
|
||||
if (isempty(optarg)) { /* --blob= deletes everything, including existing blob dirs */
|
||||
hashmap_clear(arg_blob_files);
|
||||
arg_blob_dir = mfree(arg_blob_dir);
|
||||
arg_blob_clear = true;
|
||||
break;
|
||||
}
|
||||
|
||||
eq = strrchr(optarg, '=');
|
||||
if (!eq) { /* --blob=/some/path replaces the blob dir */
|
||||
r = parse_path_argument(optarg, false, &arg_blob_dir);
|
||||
if (r < 0)
|
||||
return log_error_errno(r, "Failed to parse path %s: %m", optarg);
|
||||
break;
|
||||
}
|
||||
|
||||
/* --blob=filename=/some/path replaces the file "filename" with /some/path */
|
||||
filename = strndup(optarg, eq - optarg);
|
||||
if (!filename)
|
||||
return log_oom();
|
||||
|
||||
if (isempty(filename))
|
||||
return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Can't parse blob file assignment: %s", optarg);
|
||||
if (!suitable_blob_filename(filename))
|
||||
return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid blob filename: %s", filename);
|
||||
|
||||
r = parse_path_argument(eq + 1, false, &path);
|
||||
if (r < 0)
|
||||
return log_error_errno(r, "Failed to parse path %s: %m", eq + 1);
|
||||
} else {
|
||||
const char *well_known_filename =
|
||||
c == ARG_AVATAR ? "avatar" :
|
||||
c == ARG_LOGIN_BACKGROUND ? "login-background" :
|
||||
NULL;
|
||||
assert(well_known_filename);
|
||||
|
||||
filename = strdup(well_known_filename);
|
||||
if (!filename)
|
||||
return log_oom();
|
||||
|
||||
r = parse_path_argument(optarg, false, &path);
|
||||
if (r < 0)
|
||||
return log_error_errno(r, "Failed to parse path %s: %m", optarg);
|
||||
}
|
||||
|
||||
if (path) {
|
||||
fd = open(path, O_RDONLY|O_CLOEXEC|O_NOCTTY);
|
||||
if (fd < 0)
|
||||
return log_error_errno(errno, "Failed to open %s: %m", path);
|
||||
|
||||
if (fd_verify_regular(fd) < 0)
|
||||
return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Provided blob is not a regular file: %s", path);
|
||||
} else
|
||||
fd = -EBADF; /* Delete the file */
|
||||
|
||||
r = hashmap_ensure_put(&arg_blob_files, &blob_fd_hash_ops, filename, FD_TO_PTR(fd));
|
||||
if (r < 0)
|
||||
return log_error_errno(r, "Failed to map %s to %s in blob directory: %m", path, filename);
|
||||
TAKE_PTR(filename); /* hashmap takes ownership */
|
||||
TAKE_FD(fd);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case '?':
|
||||
return -EINVAL;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user