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
@@ -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]
@@ -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 "&lt;N&gt;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>)