diff --git a/docs/Authorization.md b/docs/Authorization.md
index bc4e7c9..dc06ab5 100644
--- a/docs/Authorization.md
+++ b/docs/Authorization.md
@@ -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
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor
index 2cefe4e..870c6e6 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor
@@ -115,6 +115,19 @@ else
+
+
+
+
+ Comma- or newline-separated; mirrors apikey create-key --dashboard-tags.
+ Matched case-insensitively against the viewer grants in
+ Dashboard:GroupToTag. Scopes dashboard event visibility only —
+ never what the key may read, write, or browse. Empty leaves the key's sessions
+ untagged, whose visibility follows Dashboard:UntaggedSessionVisibility.
+
+
@@ -238,7 +251,14 @@ else
GatewayScopes.Admin
];
- private ApiKeyCreateModel CreateModel { get; } = new();
+ ///
+ /// Backing state for the create dialog. Internal rather than private so
+ /// ApiKeysPageDashboardTagsTests can drive the model-to-request mapping
+ /// directly — the assembly's established test seam (see InternalsVisibleTo
+ /// in Properties/AssemblyInfo.cs), because a create form behind a click is
+ /// unreachable from static rendering.
+ ///
+ internal ApiKeyCreateModel CreateModel { get; } = new();
private bool CanManageApiKeys { get; set; }
@@ -399,7 +419,8 @@ else
LastGeneratedApiKey = result.ApiKey;
}
- private void OpenCreateDialog()
+ /// Opens the create dialog. Internal so a render test can reach the form's markup.
+ internal void OpenCreateDialog()
{
IsCreateDialogOpen = true;
}
@@ -412,7 +433,11 @@ else
}
}
- private bool TryBuildCreateRequest(
+ /// Maps onto a create request, or reports why it cannot.
+ /// The built request when this returns true.
+ /// The reason the model is unusable when this returns false.
+ /// True when the model produced a request.
+ 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;
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs
new file mode 100644
index 0000000..fd98b5a
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/ApiKeysPageDashboardTagsTests.cs
@@ -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;
+
+///
+/// Covers dashboard_tags on the dashboard API Keys page: the constraints
+/// column must name it, and the create form must be able to set it.
+///
+///
+///
+/// The column mattered first. ApiKeyConstraints.IsEmpty counts
+/// DashboardTags, so a key whose only per-key policy is a dashboard tag is
+/// not unconstrained — but the page's summary enumerated only the eight
+/// positional members, produced an empty string, and rendered it as -. 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.
+///
+///
+/// Rendered through the framework's static rendering infrastructure, the idiom
+/// AlarmsPageTruncationBannerTests and SessionDetailsPageEventAclTests
+/// use — the assertions are about emitted markup, so no component-testing package is
+/// warranted.
+///
+///
+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";
+
+ ///
+ /// A key whose only constraint is a dashboard tag names the tags in its constraints
+ /// cell. Before the fix this cell read -.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task ApiKeysPage_WhenKeyOnlyHasDashboardTags_NamesThemInTheConstraintsColumn()
+ {
+ string html = await RenderAsync(openCreateDialog: false);
+
+ Assert.Contains("dashboard_tags=[team-a, team-b]", html, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The control for the assertion above: a key with genuinely no constraints must still
+ /// read unconstrained, 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.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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"));
+ }
+
+ ///
+ /// The create form offers a dashboard-tags field, so a tagged key can be minted from the
+ /// dashboard rather than only from the apikey CLI.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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);
+ }
+
+ ///
+ /// The field is wired through to the request, not merely displayed: the CLI's comma
+ /// separation is honoured and each tag is trimmed.
+ ///
+ [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);
+ }
+
+ /// An empty field leaves the key untagged rather than inventing a tag.
+ [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 RenderAsync(bool openCreateDialog)
+ {
+ ServiceCollection services = new();
+ services.AddLogging();
+ services.AddSingleton(new StubSnapshotService());
+ services.AddSingleton(new IdleSnapshotFeed());
+ services.AddSingleton(new ManagingApiKeyService());
+ services.AddSingleton(new StubAuthenticationStateProvider());
+
+ await using ServiceProvider provider = services.BuildServiceProvider();
+ await using InstanceMountingHtmlRenderer renderer = new(
+ provider,
+ provider.GetRequiredService());
+
+ return await renderer.Dispatcher.InvokeAsync(async () =>
+ {
+ ApiKeysPage page = renderer.CreateComponent();
+ 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)
+ {
+ /// Instantiates a component with its [Inject] properties resolved.
+ /// Component type to create.
+ /// The component instance, not yet attached to the renderer.
+ public TComponent CreateComponent()
+ where TComponent : IComponent =>
+ (TComponent)InstantiateComponent(typeof(TComponent));
+ }
+#pragma warning restore BL0006
+
+ private sealed class StubSnapshotService : IDashboardSnapshotService
+ {
+ ///
+ 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(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(StringComparer.Ordinal),
+ Constraints: ApiKeyConstraints.Empty,
+ CreatedUtc: DateTimeOffset.UnixEpoch,
+ LastUsedUtc: null,
+ RevokedUtc: null),
+ ],
+ Configuration: null!,
+ Galaxy: null!);
+
+ ///
+ public IAsyncEnumerable 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
+ {
+ ///
+ public async IAsyncEnumerable 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
+ {
+ ///
+ public bool CanManage(ClaimsPrincipal user) => true;
+
+ ///
+ public Task CreateAsync(
+ ClaimsPrincipal user,
+ DashboardApiKeyManagementRequest request,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
+
+ ///
+ public Task RevokeAsync(
+ ClaimsPrincipal user,
+ string keyId,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
+
+ ///
+ public Task RotateAsync(
+ ClaimsPrincipal user,
+ string keyId,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
+
+ ///
+ public Task DeleteAsync(
+ ClaimsPrincipal user,
+ string keyId,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
+ }
+
+ private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
+ {
+ ///
+ public override Task 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))));
+ }
+}