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
@@ -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;
}