userdb: add birthDate field to JSON user records (#40954)

Add an optional field that can be used to store a user's birth date.
userdb already stores personal metadata (`emailAddress`, `realName`,
`location`) so `birthDate` is a natural fit.
This commit is contained in:
Luca Boccassi
2026-03-18 23:04:03 +00:00
committed by GitHub
11 changed files with 199 additions and 18 deletions
+3
View File
@@ -273,6 +273,9 @@ This must be a string, and should follow the semantics defined in the
It's probably wise to use a location string processable by geo-location subsystems, but this is not enforced nor required.
Example: `Berlin, Germany` or `Basement, Room 3a`.
`birthDate` → A string in ISO 8601 calendar date format (`YYYY-MM-DD`) indicating the user's date
of birth. The earliest representable year is 1900. This field is optional.
`disposition` → A string, one of `intrinsic`, `system`, `dynamic`, `regular`,
`container`, `foreign`, `reserved`. If specified clarifies the disposition of the user,
i.e. the context it is defined in.
+10
View File
@@ -366,6 +366,16 @@
<xi:include href="version-info.xml" xpointer="v245"/></listitem>
</varlistentry>
<varlistentry>
<term><option>--birth-date=<optional><replaceable>DATE</replaceable></optional></option></term>
<listitem><para>Takes a birth date for the user in ISO 8601 calendar date format
(<literal>YYYY-MM-DD</literal>). The earliest representable year is 1900. If an empty string is
passed the birth date is reset to unset.</para>
<xi:include href="version-info.xml" xpointer="v261"/></listitem>
</varlistentry>
<varlistentry>
<term><option>--icon-name=<replaceable>ICON</replaceable></option></term>
+48
View File
@@ -1892,3 +1892,51 @@ TimestampStyle timestamp_style_from_string(const char *s) {
return TIMESTAMP_US_UTC;
return t;
}
int parse_calendar_date_full(const char *s, bool allow_pre_epoch, usec_t *ret_usec, struct tm *ret_tm) {
struct tm parsed_tm = {}, copy_tm;
const char *k;
int r;
assert(s);
k = strptime(s, "%Y-%m-%d", &parsed_tm);
if (!k || *k)
return -EINVAL;
copy_tm = parsed_tm;
usec_t usec = USEC_INFINITY;
if (allow_pre_epoch) {
/* For birth dates we use timegm() directly since we need to accept pre-epoch dates.
* timegm() returns (time_t) -1 both on error and for one second before the epoch.
* Initialize wday to -1 beforehand: if it remains -1 after the call, it's a genuine
* error; if timegm() changed it, the date was successfully normalized. */
copy_tm.tm_wday = -1;
if (timegm(&copy_tm) == (time_t) -1 && copy_tm.tm_wday == -1)
return -EINVAL;
} else {
r = mktime_or_timegm_usec(&copy_tm, /* utc= */ true, &usec);
if (r < 0)
return r;
}
/* Refuse non-normalized dates, e.g. Feb 30 */
if (copy_tm.tm_mday != parsed_tm.tm_mday ||
copy_tm.tm_mon != parsed_tm.tm_mon ||
copy_tm.tm_year != parsed_tm.tm_year)
return -EINVAL;
if (ret_usec)
*ret_usec = usec;
if (ret_tm) {
/* Reset to unset, then fill in only the date fields we parsed and validated */
*ret_tm = BIRTH_DATE_UNSET;
ret_tm->tm_mday = parsed_tm.tm_mday;
ret_tm->tm_mon = parsed_tm.tm_mon;
ret_tm->tm_year = parsed_tm.tm_year;
}
return 0;
}
+18
View File
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#pragma once
#include <limits.h>
#include <time.h>
#include "basic-forward.h"
@@ -181,6 +182,23 @@ const char* etc_localtime(void);
int mktime_or_timegm_usec(struct tm *tm, bool utc, usec_t *ret);
int localtime_or_gmtime_usec(usec_t t, bool utc, struct tm *ret);
int parse_calendar_date_full(const char *s, bool allow_pre_epoch, usec_t *ret_usec, struct tm *ret_tm);
static inline int parse_calendar_date(const char *s, usec_t *ret) {
return parse_calendar_date_full(s, /* allow_pre_epoch= */ false, ret, NULL);
}
#define BIRTH_DATE_UNSET \
(const struct tm) { \
.tm_year = INT_MIN, \
}
#define BIRTH_DATE_IS_SET(tm) ((tm).tm_year != INT_MIN)
static inline int parse_birth_date(const char *s, struct tm *ret) {
return parse_calendar_date_full(s, /* allow_pre_epoch= */ true, NULL, ret);
}
uint32_t usec_to_jiffies(usec_t usec);
usec_t jiffies_to_usec(uint32_t jiffies);
+19
View File
@@ -3945,6 +3945,7 @@ static int help(void) {
" --alias=ALIAS Define alias usernames for this account\n"
" --email-address=EMAIL Email address for user\n"
" --location=LOCATION Set location of user on earth\n"
" --birth-date=[DATE] Set user birth date (YYYY-MM-DD)\n"
" --icon-name=NAME Icon name for user\n"
" -d --home-dir=PATH Home directory\n"
" -u --uid=UID Numeric UID for user\n"
@@ -4113,6 +4114,7 @@ static int parse_argv(int argc, char *argv[]) {
ARG_LOCKED,
ARG_SSH_AUTHORIZED_KEYS,
ARG_LOCATION,
ARG_BIRTH_DATE,
ARG_ICON_NAME,
ARG_PASSWORD_HINT,
ARG_NICE,
@@ -4199,6 +4201,7 @@ static int parse_argv(int argc, char *argv[]) {
{ "alias", required_argument, NULL, ARG_ALIAS },
{ "email-address", required_argument, NULL, ARG_EMAIL_ADDRESS },
{ "location", required_argument, NULL, ARG_LOCATION },
{ "birth-date", required_argument, NULL, ARG_BIRTH_DATE },
{ "password-hint", required_argument, NULL, ARG_PASSWORD_HINT },
{ "icon-name", required_argument, NULL, ARG_ICON_NAME },
{ "home-dir", required_argument, NULL, 'd' }, /* Compatible with useradd(8) */
@@ -4412,6 +4415,22 @@ static int parse_argv(int argc, char *argv[]) {
break;
}
case ARG_BIRTH_DATE:
if (isempty(optarg)) {
r = drop_from_identity("birthDate");
if (r < 0)
return r;
} else {
r = parse_birth_date(optarg, /* ret= */ NULL);
if (r < 0)
return log_error_errno(r, "Invalid birth date (expected YYYY-MM-DD): %s", optarg);
r = parse_string_field(&arg_identity_extra, "birthDate", optarg);
if (r < 0)
return r;
}
break;
case ARG_CIFS_SERVICE:
if (!isempty(optarg)) {
r = parse_cifs_service(optarg, /* ret_host= */ NULL, /* ret_service= */ NULL, /* ret_path= */ NULL);
+2
View File
@@ -342,6 +342,8 @@ void user_record_show(UserRecord *hr, bool show_full_group_info) {
printf(" Email: %s\n", hr->email_address);
if (hr->location)
printf(" Location: %s\n", hr->location);
if (BIRTH_DATE_IS_SET(hr->birth_date))
printf(" Birth Date: %04d-%02d-%02d\n", hr->birth_date.tm_year + 1900, hr->birth_date.tm_mon + 1, hr->birth_date.tm_mday);
if (hr->password_hint)
printf(" Passw. Hint: %s\n", hr->password_hint);
if (hr->icon_name)
+26 -1
View File
@@ -46,6 +46,7 @@ UserRecord* user_record_new(void) {
.nice_level = INT_MAX,
.not_before_usec = UINT64_MAX,
.not_after_usec = UINT64_MAX,
.birth_date = BIRTH_DATE_UNSET,
.locked = -1,
.storage = _USER_STORAGE_INVALID,
.access_mode = MODE_INVALID,
@@ -417,6 +418,28 @@ static int json_dispatch_filename_or_path(const char *name, sd_json_variant *var
return 0;
}
static int json_dispatch_birth_date(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) {
struct tm *ret = ASSERT_PTR(userdata);
const char *s;
int r;
if (sd_json_variant_is_null(variant)) {
*ret = BIRTH_DATE_UNSET;
return 0;
}
if (!sd_json_variant_is_string(variant))
return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not a string.", strna(name));
s = sd_json_variant_string(variant);
r = parse_birth_date(s, ret);
if (r < 0)
return json_log(variant, flags, r, "JSON field '%s' is not a valid ISO 8601 date (expected YYYY-MM-DD).", strna(name));
return 0;
}
static int json_dispatch_home_directory(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) {
char **s = userdata;
const char *n;
@@ -1500,7 +1523,8 @@ int user_group_record_mangle(
/* Personally Identifiable Information (PII) — avoid leaking in logs */
"realName",
"location",
"emailAddress")
"emailAddress",
"birthDate")
sd_json_variant_sensitive(sd_json_variant_by_key(v, key));
/* Check if we have the special sections and if they match our flags set */
@@ -1595,6 +1619,7 @@ int user_record_load(UserRecord *h, sd_json_variant *v, UserRecordLoadFlags load
{ "emailAddress", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, email_address), SD_JSON_STRICT },
{ "iconName", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, icon_name), SD_JSON_STRICT },
{ "location", SD_JSON_VARIANT_STRING, sd_json_dispatch_string, offsetof(UserRecord, location), 0 },
{ "birthDate", SD_JSON_VARIANT_STRING, json_dispatch_birth_date, offsetof(UserRecord, birth_date), 0 },
{ "disposition", SD_JSON_VARIANT_STRING, json_dispatch_user_disposition, offsetof(UserRecord, disposition), 0 },
{ "lastChangeUSec", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, offsetof(UserRecord, last_change_usec), 0 },
{ "lastPasswordChangeUSec", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, offsetof(UserRecord, last_password_change_usec), 0 },
+3
View File
@@ -1,6 +1,8 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#pragma once
#include <time.h>
#include "sd-id128.h"
#include "bitfield.h"
@@ -266,6 +268,7 @@ typedef struct UserRecord {
char *password_hint;
char *icon_name;
char *location;
struct tm birth_date;
char *blob_directory;
Hashmap *blob_manifest;
+3 -17
View File
@@ -433,24 +433,10 @@ static int process_magic_file(
if (iovec_memcmp(&IOVEC_MAKE(expected_hash, sizeof(expected_hash)), hash) != 0)
log_warning("Hash of best before marker file '%s' has unexpected value, proceeding anyway.", fn);
struct tm parsed_tm = {};
const char *n = strptime(e, "%Y-%m-%d", &parsed_tm);
if (!n || *n != 0) {
/* Doesn't parse? Then it's not a best-before date */
log_warning("Found best before marker with an invalid date, ignoring: %s", fn);
return 0;
}
struct tm copy_tm = parsed_tm;
usec_t best_before;
r = mktime_or_timegm_usec(&copy_tm, /* utc= */ true, &best_before);
if (r < 0)
return log_error_errno(r, "Failed to convert best before time: %m");
if (copy_tm.tm_mday != parsed_tm.tm_mday ||
copy_tm.tm_mon != parsed_tm.tm_mon ||
copy_tm.tm_year != parsed_tm.tm_year) {
/* date was not normalized? (e.g. "30th of feb") */
log_warning("Found best before marker with a non-normalized data, ignoring: %s", fn);
r = parse_calendar_date(e, &best_before);
if (r < 0) {
log_warning_errno(r, "Found best before marker with an invalid date, ignoring: %s", fn);
return 0;
}
+58
View File
@@ -1281,4 +1281,62 @@ static int intro(void) {
return EXIT_SUCCESS;
}
TEST(parse_calendar_date) {
usec_t usec;
/* Valid dates */
ASSERT_OK(parse_calendar_date("2000-01-01", &usec));
ASSERT_OK(parse_calendar_date("1970-01-01", &usec));
ASSERT_EQ(usec, 0u); /* epoch */
ASSERT_OK(parse_calendar_date("2000-02-29", &usec)); /* leap year */
/* NULL ret is allowed (validation only) */
ASSERT_OK(parse_calendar_date("2000-06-15", NULL));
/* Non-normalized dates */
ASSERT_ERROR(parse_calendar_date("2023-02-29", &usec), EINVAL); /* not a leap year */
ASSERT_ERROR(parse_calendar_date("2023-04-31", &usec), EINVAL); /* April has 30 days */
ASSERT_ERROR(parse_calendar_date("2023-13-01", &usec), EINVAL); /* month 13 */
ASSERT_ERROR(parse_calendar_date("2023-00-01", &usec), EINVAL); /* month 0 */
/* Malformed input */
ASSERT_ERROR(parse_calendar_date("", &usec), EINVAL);
ASSERT_ERROR(parse_calendar_date("not-a-date", &usec), EINVAL);
ASSERT_ERROR(parse_calendar_date("2023-06-15T00:00:00", &usec), EINVAL); /* trailing time */
ASSERT_ERROR(parse_calendar_date("2023/06/15", &usec), EINVAL); /* wrong separator */
ASSERT_ERROR(parse_calendar_date("06-15-2023", &usec), EINVAL); /* wrong order */
}
TEST(parse_birth_date) {
struct tm tm;
/* Valid dates */
ASSERT_OK(parse_birth_date("2000-06-15", &tm));
ASSERT_EQ(tm.tm_year, 100); /* 2000 - 1900 */
ASSERT_EQ(tm.tm_mon, 5); /* June, 0-indexed */
ASSERT_EQ(tm.tm_mday, 15);
/* Pre-epoch dates */
ASSERT_OK(parse_birth_date("1960-03-25", &tm));
ASSERT_EQ(tm.tm_year, 60);
ASSERT_EQ(tm.tm_mon, 2);
ASSERT_EQ(tm.tm_mday, 25);
/* NULL ret is allowed (validation only) */
ASSERT_OK(parse_birth_date("2000-01-01", NULL));
/* Non-date fields should not be relied upon */
ASSERT_OK(parse_birth_date("2000-06-15", &tm));
ASSERT_FALSE(BIRTH_DATE_IS_SET(BIRTH_DATE_UNSET));
/* Non-normalized dates */
ASSERT_ERROR(parse_birth_date("2023-02-29", &tm), EINVAL);
ASSERT_ERROR(parse_birth_date("2023-04-31", &tm), EINVAL);
/* Malformed input */
ASSERT_ERROR(parse_birth_date("", &tm), EINVAL);
ASSERT_ERROR(parse_birth_date("not-a-date", &tm), EINVAL);
ASSERT_ERROR(parse_birth_date("2023-06-15T00:00:00", &tm), EINVAL);
}
DEFINE_TEST_MAIN_WITH_INTRO(LOG_INFO, intro);
+9
View File
@@ -96,6 +96,15 @@ TEST(self_changes) {
SD_JSON_BUILD_PAIR_OBJECT("privileged",
SD_JSON_BUILD_PAIR_UNSIGNED("notInHardCodedList", 99999)));
ASSERT_TRUE(user_record_self_changes_allowed(curr, new));
/* birthDate is NOT self-modifiable (admin-only) */
USER(&curr,
SD_JSON_BUILD_PAIR_STRING("userName", "test"),
SD_JSON_BUILD_PAIR_STRING("birthDate", "1990-01-01"));
USER(&new,
SD_JSON_BUILD_PAIR_STRING("userName", "test"),
SD_JSON_BUILD_PAIR_STRING("birthDate", "1990-06-15"));
ASSERT_FALSE(user_record_self_changes_allowed(curr, new));
}
DEFINE_TEST_MAIN(LOG_INFO);