feat(security): apikey --expires CLI flag + dashboard expiry/staleness badge (SEC-10 polish)
ci / windows (push) Waiting to run
ci / live-mxaccess (push) Waiting to run
ci / windows (pull_request) Waiting to run
ci / live-mxaccess (pull_request) Waiting to run
ci / portable (push) Failing after 44s
ci / java (push) Failing after 50s
ci / java (pull_request) Failing after 37s
ci / portable (pull_request) Failing after 55s

Gateway-side follow-up to the shared auth-lib expiry core (delivered via G-2):

- apikey create-key gains optional --expires — absolute ISO-8601 UTC or relative
  <N>d/<N>h from now; omitted means non-expiring (opt-in, unchanged default).
  Threaded ApiKeyAdminCommand -> parser -> runner into the library's
  CreateKeyAsync(..., expiresUtc, ...). list-keys shows an expiry column and an
  active/expired/revoked status.
- Dashboard API Keys page surfaces expiry: an Expires column (Never when unset)
  and a status badge reading Expired (red) / Expiring (<=7d, amber) / Revoked /
  Active. DashboardApiKeySummary.ExpiresUtc projected in DashboardSnapshotService;
  StatusBadge maps the new states.

Tests: parser (absolute/relative/invalid/none) + end-to-end past-expiry rejection
through the live verifier; dashboard summary suite green. Docs: Authentication.md
(verification-flow expiry step, CLI table + examples, dashboard badge).

Closes the tracked SEC-10 polish (SEC-10 core was already Done).
This commit is contained in:
Joseph Doherty
2026-07-09 09:48:09 -04:00
parent de57d834b1
commit a038363e74
12 changed files with 192 additions and 14 deletions
@@ -82,9 +82,11 @@ public static class ApiKeyAdminCommandLineParser
string? displayName = GetOption(options, "display-name");
IReadOnlySet<string> scopes = ParseScopes(GetOption(options, "scopes"));
ApiKeyConstraints constraints;
DateTimeOffset? expiresUtc;
try
{
constraints = ParseConstraints(options);
expiresUtc = ParseExpiry(GetOption(options, "expires"));
}
catch (FormatException exception)
{
@@ -111,7 +113,8 @@ public static class ApiKeyAdminCommandLineParser
KeyId: keyId,
DisplayName: displayName,
Scopes: scopes,
Constraints: constraints));
Constraints: constraints,
ExpiresUtc: expiresUtc));
}
private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind)
@@ -233,6 +236,42 @@ public static class ApiKeyAdminCommandLineParser
ReadHistorizedOnly: HasFlag(options, "read-historized-only"));
}
// Parses the optional --expires value into an absolute UTC expiry (SEC-10). Accepts a relative
// "<N>d"/"<N>h" duration from now (operator-friendly) or an absolute ISO-8601 instant/date
// (assumed UTC). Null/blank means no expiry — expiry stays opt-in, preserving prior behaviour.
private static DateTimeOffset? ParseExpiry(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string trimmed = value.Trim();
char unit = char.ToLowerInvariant(trimmed[^1]);
if (unit is 'd' or 'h'
&& int.TryParse(
trimmed[..^1],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int amount))
{
TimeSpan offset = unit == 'd' ? TimeSpan.FromDays(amount) : TimeSpan.FromHours(amount);
return DateTimeOffset.UtcNow + offset;
}
if (DateTimeOffset.TryParse(
trimmed,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out DateTimeOffset parsed))
{
return parsed;
}
throw new FormatException(
"--expires must be a relative duration ('<N>d' or '<N>h') or an absolute ISO-8601 UTC timestamp.");
}
private static int? ParseNullableInt(string? value)
{
if (string.IsNullOrWhiteSpace(value))