feat(dashboard): ApiKeysPage lists and accepts dashboard_tags constraints

The constraints column enumerated only the eight positional ApiKeyConstraints
members, so a key whose sole recorded policy was a dashboard tag summarised to
an empty string and rendered as "-" — the same cell a key with no policy at
all gets. ApiKeyConstraints.IsEmpty counts DashboardTags, so that key is not
unconstrained, and the column was quietly telling operators otherwise about a
grant that decides who can watch a session's events.

The create form had no dashboard-tags input either, so tagged keys could only
be minted from the apikey create-key CLI. Adds the field beside the other
constraint lists (same ParseList separators) and attaches it through the
record's init-only member, since it postdates the eight-member constructor.

CreateModel, OpenCreateDialog and TryBuildCreateRequest widen to internal for
the new render tests: the create form is behind a click and static rendering
cannot dispatch one. That is the assembly's existing InternalsVisibleTo seam.
This commit is contained in:
Joseph Doherty
2026-08-17 07:17:43 -04:00
parent fccf75324b
commit c037d9960d
3 changed files with 343 additions and 7 deletions
+8 -2
View File
@@ -195,8 +195,14 @@ another tenant's tag. A key with no tags opens untagged sessions.
Tags are set at key creation with Tags are set at key creation with
`apikey create-key --dashboard-tags team-a,team-b` (repeatable; segments are `apikey create-key --dashboard-tags team-a,team-b` (repeatable; segments are
trimmed and de-duplicated ordinal-ignore-case). Keys created from the dashboard trimmed and de-duplicated ordinal-ignore-case). The dashboard API Keys page sets
API Keys page are currently always untagged. them too: its create form has a **Dashboard tags** field alongside the data-access
constraints, split on the same separators the other constraint fields use.
That page's constraints column names `dashboard_tags` like any other member. It
has to: `IsEmpty` counts the tags, so a key whose only recorded policy is a
dashboard tag is not unconstrained, and leaving it out of the summary rendered
that key with the same empty cell as a key with no policy at all.
The dashboard ACL that consumes the tag shipped on 2026-08-17 (SEC-25 / TST-15). The dashboard ACL that consumes the tag shipped on 2026-08-17 (SEC-25 / TST-15).
`IDashboardSessionAcl.CanViewSession` is consulted at both dashboard subscribe `IDashboardSessionAcl.CanViewSession` is consulted at both dashboard subscribe
@@ -115,6 +115,19 @@ else
<label for="browseSubtrees" class="form-label small">Browse subtrees</label> <label for="browseSubtrees" class="form-label small">Browse subtrees</label>
<textarea id="browseSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea> <textarea id="browseSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
</div> </div>
<div class="mb-2">
<label for="dashboardTags" class="form-label small">Dashboard tags</label>
<textarea id="dashboardTags" class="form-control form-control-sm" rows="2"
aria-describedby="dashboardTagsHelp"
@bind="CreateModel.DashboardTags" @bind:event="oninput"></textarea>
<div id="dashboardTagsHelp" class="form-text small">
Comma- or newline-separated; mirrors <code>apikey create-key --dashboard-tags</code>.
Matched case-insensitively against the viewer grants in
<code>Dashboard:GroupToTag</code>. Scopes dashboard event visibility only —
never what the key may read, write, or browse. Empty leaves the key's sessions
untagged, whose visibility follows <code>Dashboard:UntaggedSessionVisibility</code>.
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="maxWriteClassification" class="form-label small">Max write classification</label> <label for="maxWriteClassification" class="form-label small">Max write classification</label>
<input id="maxWriteClassification" class="form-control form-control-sm" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" /> <input id="maxWriteClassification" class="form-control form-control-sm" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
@@ -238,7 +251,14 @@ else
GatewayScopes.Admin GatewayScopes.Admin
]; ];
private ApiKeyCreateModel CreateModel { get; } = new(); /// <summary>
/// Backing state for the create dialog. Internal rather than private so
/// <c>ApiKeysPageDashboardTagsTests</c> can drive the model-to-request mapping
/// directly — the assembly's established test seam (see <c>InternalsVisibleTo</c>
/// in <c>Properties/AssemblyInfo.cs</c>), because a create form behind a click is
/// unreachable from static rendering.
/// </summary>
internal ApiKeyCreateModel CreateModel { get; } = new();
private bool CanManageApiKeys { get; set; } private bool CanManageApiKeys { get; set; }
@@ -399,7 +419,8 @@ else
LastGeneratedApiKey = result.ApiKey; LastGeneratedApiKey = result.ApiKey;
} }
private void OpenCreateDialog() /// <summary>Opens the create dialog. Internal so a render test can reach the form's markup.</summary>
internal void OpenCreateDialog()
{ {
IsCreateDialogOpen = true; IsCreateDialogOpen = true;
} }
@@ -412,7 +433,11 @@ else
} }
} }
private bool TryBuildCreateRequest( /// <summary>Maps <see cref="CreateModel"/> onto a create request, or reports why it cannot.</summary>
/// <param name="request">The built request when this returns true.</param>
/// <param name="validationMessage">The reason the model is unusable when this returns false.</param>
/// <returns>True when the model produced a request.</returns>
internal bool TryBuildCreateRequest(
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
out string? validationMessage) out string? validationMessage)
{ {
@@ -449,7 +474,12 @@ else
MaxWriteClassification: maxWriteClassification, MaxWriteClassification: maxWriteClassification,
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees), BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
ReadAlarmOnly: CreateModel.ReadAlarmOnly, ReadAlarmOnly: CreateModel.ReadAlarmOnly,
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly)); ReadHistorizedOnly: CreateModel.ReadHistorizedOnly)
{
// Init-only rather than positional (it was bolted onto the record after the
// eight-member constructor shipped), so it is attached here instead.
DashboardTags = ParseList(CreateModel.DashboardTags),
});
return true; return true;
} }
@@ -514,6 +544,11 @@ else
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs); AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs); AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
AddList(parts, "browse_subtrees", constraints.BrowseSubtrees); AddList(parts, "browse_subtrees", constraints.BrowseSubtrees);
// Listed like the rest even though it restricts no data path: IsEmpty counts it, so a key
// whose only policy is a dashboard tag is not "unconstrained", and omitting it here left
// that key's cell empty — rendered as "-", the same cell a key with no policy at all gets.
AddList(parts, "dashboard_tags", constraints.DashboardTags);
if (constraints.MaxWriteClassification is { } max) if (constraints.MaxWriteClassification is { } max)
{ {
parts.Add($"max_write_classification={max}"); parts.Add($"max_write_classification={max}");
@@ -548,7 +583,7 @@ else
.ToArray(); .ToArray();
} }
private sealed class ApiKeyCreateModel internal sealed class ApiKeyCreateModel
{ {
public string KeyId { get; set; } = string.Empty; public string KeyId { get; set; } = string.Empty;
@@ -568,6 +603,8 @@ else
public string MaxWriteClassification { get; set; } = string.Empty; public string MaxWriteClassification { get; set; } = string.Empty;
public string DashboardTags { get; set; } = string.Empty;
public bool ReadAlarmOnly { get; set; } public bool ReadAlarmOnly { get; set; }
public bool ReadHistorizedOnly { get; set; } public bool ReadHistorizedOnly { get; set; }
@@ -583,6 +620,7 @@ else
WriteTagGlobs = string.Empty; WriteTagGlobs = string.Empty;
BrowseSubtrees = string.Empty; BrowseSubtrees = string.Empty;
MaxWriteClassification = string.Empty; MaxWriteClassification = string.Empty;
DashboardTags = string.Empty;
ReadAlarmOnly = false; ReadAlarmOnly = false;
ReadHistorizedOnly = false; ReadHistorizedOnly = false;
} }
@@ -0,0 +1,292 @@
using System.Runtime.CompilerServices;
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Covers <c>dashboard_tags</c> on the dashboard API Keys page: the constraints
/// column must name it, and the create form must be able to set it.
/// </summary>
/// <remarks>
/// <para>
/// The column mattered first. <c>ApiKeyConstraints.IsEmpty</c> counts
/// <c>DashboardTags</c>, so a key whose only per-key policy is a dashboard tag is
/// <em>not</em> unconstrained — but the page's summary enumerated only the eight
/// positional members, produced an empty string, and rendered it as <c>-</c>. An
/// operator auditing keys saw the same cell for "no policy recorded" and "scoped to
/// team-a", which is the reading a tag grant can least afford.
/// </para>
/// <para>
/// Rendered through the framework's static rendering infrastructure, the idiom
/// <c>AlarmsPageTruncationBannerTests</c> and <c>SessionDetailsPageEventAclTests</c>
/// use — the assertions are about emitted markup, so no component-testing package is
/// warranted.
/// </para>
/// </remarks>
public sealed class ApiKeysPageDashboardTagsTests
{
// Deliberately free of the words the assertions search for: a key id containing
// "unconstrained" would be counted as a summary and mask a regression in the cell.
private const string TagsOnlyKeyId = "key-tagged";
private const string UnconstrainedKeyId = "key-plain";
/// <summary>
/// A key whose only constraint is a dashboard tag names the tags in its constraints
/// cell. Before the fix this cell read <c>-</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeysPage_WhenKeyOnlyHasDashboardTags_NamesThemInTheConstraintsColumn()
{
string html = await RenderAsync(openCreateDialog: false);
Assert.Contains("dashboard_tags=[team-a, team-b]", html, StringComparison.Ordinal);
}
/// <summary>
/// The control for the assertion above: a key with genuinely no constraints must still
/// read <c>unconstrained</c>, and only that key may. Without this, a summary that
/// labelled every key would satisfy the positive case while erasing the distinction the
/// column exists to draw.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeysPage_WhenKeyHasNoConstraints_StillReadsUnconstrained()
{
string html = await RenderAsync(openCreateDialog: false);
Assert.Contains(UnconstrainedKeyId, html, StringComparison.Ordinal);
Assert.Contains("unconstrained", html, StringComparison.Ordinal);
// Exactly one row may claim it — the tags-only key is constrained, by IsEmpty's own reckoning.
Assert.Equal(1, CountOccurrences(html, "unconstrained"));
}
/// <summary>
/// The create form offers a dashboard-tags field, so a tagged key can be minted from the
/// dashboard rather than only from the <c>apikey</c> CLI.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeysPage_CreateForm_OffersADashboardTagsField()
{
string html = await RenderAsync(openCreateDialog: true);
// The sibling constraint field is the control: it proves the form rendered at all, so a
// dialog that failed to open could not pass this by rendering nothing.
Assert.Contains("id=\"readSubtrees\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"dashboardTags\"", html, StringComparison.Ordinal);
Assert.Contains("Dashboard tags", html, StringComparison.Ordinal);
}
/// <summary>
/// The field is wired through to the request, not merely displayed: the CLI's comma
/// separation is honoured and each tag is trimmed.
/// </summary>
[Fact]
public void TryBuildCreateRequest_CarriesTheDashboardTagsField()
{
ApiKeysPage page = new();
page.CreateModel.DashboardTags = "team-a, team-b";
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
Assert.Equal(["team-a", "team-b"], request.Constraints.DashboardTags);
// Nothing else may be inferred from a tags-only form: dashboard tags are a visibility
// grant, and turning one into a data-access constraint would be a silent policy change.
Assert.False(request.Constraints.IsEmpty);
Assert.False(request.Constraints.HasReadConstraints);
Assert.False(request.Constraints.HasWriteConstraints);
}
/// <summary>An empty field leaves the key untagged rather than inventing a tag.</summary>
[Fact]
public void TryBuildCreateRequest_WhenDashboardTagsIsBlank_LeavesTheKeyUntagged()
{
ApiKeysPage page = new();
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
Assert.Empty(request.Constraints.DashboardTags);
Assert.True(request.Constraints.IsEmpty);
}
private static int CountOccurrences(string haystack, string needle)
{
int count = 0;
int index = haystack.IndexOf(needle, StringComparison.Ordinal);
while (index >= 0)
{
count++;
index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal);
}
return count;
}
private static async Task<string> RenderAsync(bool openCreateDialog)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
services.AddSingleton<IDashboardApiKeyManagementService>(new ManagingApiKeyService());
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider());
await using ServiceProvider provider = services.BuildServiceProvider();
await using InstanceMountingHtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
ApiKeysPage page = renderer.CreateComponent<ApiKeysPage>();
if (openCreateDialog)
{
page.OpenCreateDialog();
}
HtmlRootComponent output = renderer.BeginRenderingComponent(page, ParameterView.Empty);
await output.QuiescenceTask;
return output.ToHtmlString();
});
}
// The create form lives behind a click, and static rendering has no way to dispatch one, so
// the dialog is opened on the instance before it is handed to the renderer. Reaching a
// pre-built instance needs Renderer.InstantiateComponent (which is what performs [Inject]
// property injection); the sealed HtmlRenderer used by the sibling tests exposes no such seam,
// so this subclasses the same static-rendering infrastructure HtmlRenderer itself wraps.
//
// BL0006 warns that RenderTree types are not for use outside the Blazor framework. Suppressed
// here and only here, exactly as SessionDetailsPageEventAclTests does: this is test-only
// scaffolding that never ships, and the cost of the warning coming true is a compile break in
// one test file on an SDK bump. Production code must keep honouring BL0006.
#pragma warning disable BL0006
private sealed class InstanceMountingHtmlRenderer(IServiceProvider services, ILoggerFactory loggerFactory)
: StaticHtmlRenderer(services, loggerFactory)
{
/// <summary>Instantiates a component with its <c>[Inject]</c> properties resolved.</summary>
/// <typeparam name="TComponent">Component type to create.</typeparam>
/// <returns>The component instance, not yet attached to the renderer.</returns>
public TComponent CreateComponent<TComponent>()
where TComponent : IComponent =>
(TComponent)InstantiateComponent(typeof(TComponent));
}
#pragma warning restore BL0006
private sealed class StubSnapshotService : IDashboardSnapshotService
{
/// <inheritdoc />
public DashboardSnapshot GetSnapshot() => new(
GeneratedAt: DateTimeOffset.UnixEpoch,
GatewayStartedAt: DateTimeOffset.UnixEpoch,
GatewayUptime: TimeSpan.Zero,
GatewayStatus: "Healthy",
GatewayVersion: "test",
Sessions: [],
Workers: [],
Metrics: [],
Faults: [],
ApiKeys:
[
new DashboardApiKeySummary(
KeyId: TagsOnlyKeyId,
DisplayName: "Tags only",
Scopes: new HashSet<string>(StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with { DashboardTags = ["team-a", "team-b"] },
CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null,
RevokedUtc: null),
new DashboardApiKeySummary(
KeyId: UnconstrainedKeyId,
DisplayName: "Unconstrained",
Scopes: new HashSet<string>(StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null,
RevokedUtc: null),
],
Configuration: null!,
Galaxy: null!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
new IdleSnapshotFeed().WatchAsync(cancellationToken);
}
// Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
// second snapshot mid-render.
private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
yield break;
}
}
// Grants management so the create dialog is reachable; no test here exercises a mutation.
private sealed class ManagingApiKeyService : IDashboardApiKeyManagementService
{
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user) => true;
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
}
private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
{
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "admin-user"), new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role))));
}
}