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:
@@ -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);
|
||||
|
||||
+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);
|
||||
|
||||
@@ -52,6 +52,40 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
||||
Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a key created with an already-past <c>--expires</c> is rejected by the verifier
|
||||
/// — the CLI expiry wiring reaches the store and the library enforces it end-to-end (SEC-10).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CreateKeyAsync_WithPastExpiry_KeyIsRejectedByVerifier()
|
||||
{
|
||||
await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
|
||||
ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
|
||||
StringWriter output = new();
|
||||
|
||||
await runner.RunAsync(
|
||||
new ApiKeyAdminCommand(
|
||||
Kind: ApiKeyAdminCommandKind.CreateKey,
|
||||
Json: true,
|
||||
SqlitePath: null,
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open" },
|
||||
Constraints: ApiKeyConstraints.Empty,
|
||||
ExpiresUtc: DateTimeOffset.UtcNow - TimeSpan.FromMinutes(1)),
|
||||
output,
|
||||
CancellationToken.None);
|
||||
|
||||
string apiKey = ReadApiKey(output.ToString());
|
||||
|
||||
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
|
||||
ApiKeyVerification verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
||||
|
||||
Assert.False(verification.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ListKeysAsync does not print the raw secret.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
+55
@@ -52,6 +52,61 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
||||
Assert.Contains("events:read", result.Command.Scopes);
|
||||
}
|
||||
|
||||
/// <summary>A create-key command without --expires leaves the key non-expiring (opt-in, SEC-10).</summary>
|
||||
[Fact]
|
||||
public void Parse_CreateKeyCommand_WithoutExpires_HasNoExpiry()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator", "--scopes", "session:open"]);
|
||||
|
||||
Assert.NotNull(result.Command);
|
||||
Assert.Null(result.Command.ExpiresUtc);
|
||||
}
|
||||
|
||||
/// <summary>An absolute ISO-8601 --expires value is parsed to that exact UTC instant (SEC-10).</summary>
|
||||
[Fact]
|
||||
public void Parse_CreateKeyCommand_WithAbsoluteExpires_ParsesInstant()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator", "--scopes", "session:open",
|
||||
"--expires", "2030-01-02T03:04:05Z"]);
|
||||
|
||||
Assert.NotNull(result.Command);
|
||||
Assert.Equal(
|
||||
new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero),
|
||||
result.Command.ExpiresUtc);
|
||||
}
|
||||
|
||||
/// <summary>A relative "<N>d" --expires value resolves to a future UTC instant (SEC-10).</summary>
|
||||
[Fact]
|
||||
public void Parse_CreateKeyCommand_WithRelativeExpires_ResolvesToFuture()
|
||||
{
|
||||
DateTimeOffset before = DateTimeOffset.UtcNow;
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator", "--scopes", "session:open",
|
||||
"--expires", "30d"]);
|
||||
|
||||
Assert.NotNull(result.Command);
|
||||
Assert.NotNull(result.Command.ExpiresUtc);
|
||||
Assert.InRange(
|
||||
result.Command.ExpiresUtc!.Value,
|
||||
before + TimeSpan.FromDays(30) - TimeSpan.FromMinutes(1),
|
||||
DateTimeOffset.UtcNow + TimeSpan.FromDays(30) + TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
/// <summary>An unparseable --expires value fails at parse time (SEC-10).</summary>
|
||||
[Fact]
|
||||
public void Parse_CreateKeyCommand_WithInvalidExpires_Fails()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
["apikey", "create-key", "--key-id", "operator01", "--display-name", "Operator", "--scopes", "session:open",
|
||||
"--expires", "not-a-date"]);
|
||||
|
||||
Assert.Null(result.Command);
|
||||
Assert.NotNull(result.Error);
|
||||
Assert.Contains("--expires", result.Error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A create-key command with a non-canonical scope
|
||||
/// string (e.g. CLAUDE.md's stale <c>invoke</c> instead of <c>invoke:read</c>)
|
||||
|
||||
Reference in New Issue
Block a user