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)))); + } +}