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:
@@ -195,8 +195,14 @@ another tenant's tag. A key with no tags opens untagged sessions.
|
||||
|
||||
Tags are set at key creation with
|
||||
`apikey create-key --dashboard-tags team-a,team-b` (repeatable; segments are
|
||||
trimmed and de-duplicated ordinal-ignore-case). Keys created from the dashboard
|
||||
API Keys page are currently always untagged.
|
||||
trimmed and de-duplicated ordinal-ignore-case). The dashboard API Keys page sets
|
||||
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).
|
||||
`IDashboardSessionAcl.CanViewSession` is consulted at both dashboard subscribe
|
||||
|
||||
@@ -115,6 +115,19 @@ else
|
||||
<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>
|
||||
</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">
|
||||
<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" />
|
||||
@@ -238,7 +251,14 @@ else
|
||||
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; }
|
||||
|
||||
@@ -399,7 +419,8 @@ else
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
out string? validationMessage)
|
||||
{
|
||||
@@ -449,7 +474,12 @@ else
|
||||
MaxWriteClassification: maxWriteClassification,
|
||||
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
|
||||
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;
|
||||
}
|
||||
@@ -514,6 +544,11 @@ else
|
||||
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
|
||||
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
|
||||
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)
|
||||
{
|
||||
parts.Add($"max_write_classification={max}");
|
||||
@@ -548,7 +583,7 @@ else
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed class ApiKeyCreateModel
|
||||
internal sealed class ApiKeyCreateModel
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
@@ -568,6 +603,8 @@ else
|
||||
|
||||
public string MaxWriteClassification { get; set; } = string.Empty;
|
||||
|
||||
public string DashboardTags { get; set; } = string.Empty;
|
||||
|
||||
public bool ReadAlarmOnly { get; set; }
|
||||
|
||||
public bool ReadHistorizedOnly { get; set; }
|
||||
@@ -583,6 +620,7 @@ else
|
||||
WriteTagGlobs = string.Empty;
|
||||
BrowseSubtrees = string.Empty;
|
||||
MaxWriteClassification = string.Empty;
|
||||
DashboardTags = string.Empty;
|
||||
ReadAlarmOnly = 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))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user