feat(security): apikey --expires CLI flag + dashboard expiry/staleness badge (SEC-10 polish)
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
ci / windows (push) Failing after 8s
ci / windows (pull_request) Failing after 8s
ci / live-mxaccess (push) Has been cancelled
ci / live-mxaccess (pull_request) Has been cancelled

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
@@ -164,6 +164,7 @@ else
<th scope="col">Constraints</th>
<th scope="col">Created</th>
<th scope="col">Last Used</th>
<th scope="col">Expires</th>
@if (CanManageApiKeys)
{
<th scope="col">Actions</th>
@@ -175,12 +176,13 @@ else
{
<tr>
<td><code>@key.KeyId</code></td>
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
<td><StatusBadge Text="@KeyStatus(key)" /></td>
<td>@DashboardDisplay.Text(key.DisplayName)</td>
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
<td>@(key.ExpiresUtc is null ? "Never" : DashboardDisplay.DateTime(key.ExpiresUtc))</td>
@if (CanManageApiKeys)
{
<td>
@@ -468,6 +470,35 @@ else
}
}
// Window before an expiry within which a key is flagged as "Expiring" (warn) rather than "Active".
private static readonly TimeSpan ExpiringSoonWindow = TimeSpan.FromDays(7);
// Status vocabulary understood by StatusBadge: Revoked wins over expiry; a past expiry is Expired
// (bad), an expiry inside ExpiringSoonWindow is Expiring (warn), otherwise Active (SEC-10).
private static string KeyStatus(DashboardApiKeySummary key)
{
if (key.RevokedUtc is not null)
{
return "Revoked";
}
if (key.ExpiresUtc is { } expiresAt)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
if (expiresAt <= now)
{
return "Expired";
}
if (expiresAt - now <= ExpiringSoonWindow)
{
return "Expiring";
}
}
return "Active";
}
private static string ConstraintText(ApiKeyConstraints constraints)
{
if (constraints.IsEmpty)
@@ -9,8 +9,8 @@
{
"Ready" or "Healthy" or "Active" => StatusState.Ok,
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing"
or "Stale" or "Degraded" => StatusState.Warn,
"Faulted" or "Unavailable" => StatusState.Bad,
or "Stale" or "Degraded" or "Expiring" => StatusState.Warn,
"Faulted" or "Unavailable" or "Expired" => StatusState.Bad,
_ => StatusState.Idle,
};
}
@@ -9,4 +9,5 @@ public sealed record DashboardApiKeySummary(
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
DateTimeOffset? RevokedUtc,
DateTimeOffset? ExpiresUtc = null);
@@ -273,7 +273,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc))
RevokedUtc: key.RevokedUtc,
ExpiresUtc: key.ExpiresUtc))
.ToArray();
Volatile.Write(ref _apiKeySummaries, summaries);
@@ -67,6 +67,7 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
Required(command.DisplayName),
command.Scopes,
ApiKeyConstraintSerializer.Serialize(command.Constraints),
command.ExpiresUtc,
remoteAddress: null,
cancellationToken)
.ConfigureAwait(false);
@@ -134,8 +135,16 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
foreach (ApiKeyAdminListedKey key in result.Keys)
{
string revoked = key.RevokedUtc is null ? "active" : "revoked";
await output.WriteLineAsync($"{key.KeyId}\t{key.DisplayName}\t{revoked}\t{string.Join(',', key.Scopes)}")
string status = key.RevokedUtc is not null
? "revoked"
: key.ExpiresUtc is { } expiresAt && expiresAt <= DateTimeOffset.UtcNow
? "expired"
: "active";
string expiry = key.ExpiresUtc is { } expires
? expires.ToUniversalTime().ToString("u", System.Globalization.CultureInfo.InvariantCulture)
: "-";
await output.WriteLineAsync(
$"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}")
.ConfigureAwait(false);
}
}
@@ -150,7 +159,8 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc);
RevokedUtc: key.RevokedUtc,
ExpiresUtc: key.ExpiresUtc);
}
private static string Required(string? value)
@@ -8,4 +8,5 @@ public sealed record ApiKeyAdminCommand(
string? KeyId,
string? DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints);
ApiKeyConstraints Constraints,
DateTimeOffset? ExpiresUtc = null);
@@ -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))
@@ -8,4 +8,5 @@ public sealed record ApiKeyAdminListedKey(
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
DateTimeOffset? RevokedUtc,
DateTimeOffset? ExpiresUtc = null);