feat(security): apikey --expires CLI flag + dashboard expiry/staleness badge (SEC-10 polish)
ci / live-mxaccess (push) 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
ci / windows (push) Failing after 8s
ci / windows (pull_request) Failing after 8s
ci / live-mxaccess (push) 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
ci / windows (push) Failing after 8s
ci / windows (pull_request) Failing after 8s
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:
@@ -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);
|
||||
|
||||
+40
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user