fix(dashboard): de-duplicate dashboard_tags typed into the API-key create form

The form split tags with the shared ParseList and attached the result verbatim,
so "team-a, TEAM-A" persisted as two entries and the constraints column read
dashboard_tags=[team-a, TEAM-A] — one grant reported as two on the page whose
job is to show what a key was granted. Enforcement never saw it (a session holds
its tags in a case-insensitive set), which is exactly why the display was the
only place it could surface.

De-duplicates ordinal-ignore-case at the attach point only, first spelling
winning, matching ApiKeyAdminCommandLineParser.ParseDashboardTags. ParseList is
untouched: the five glob lists are matched literally, so near-duplicates there
are not necessarily the same rule and must survive verbatim — pinned by a test.

The help text claimed to mirror the CLI flag; it now claims only the shared
separators and the dedupe, since the form still drops an empty segment silently
where the CLI hard-fails. A browser form has no exit code to fail with, so that
difference stays, and Authorization.md now records it.
This commit is contained in:
Joseph Doherty
2026-08-18 05:19:02 -04:00
parent 1ea6f60ea2
commit fab600d3b0
3 changed files with 85 additions and 3 deletions
@@ -121,7 +121,8 @@ else
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>.
Split on the same separators as the constraint fields above, and
de-duplicated case-insensitively — two spellings of a tag are one grant.
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
@@ -478,7 +479,7 @@ else
{
// 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),
DashboardTags = ParseDashboardTags(CreateModel.DashboardTags),
});
return true;
@@ -575,6 +576,18 @@ else
}
}
// Dashboard tags alone are de-duplicated ordinal-ignore-case, which is how the enforcement site
// compares them — two spellings are one grant, and ApiKeyAdminCommandLineParser.ParseDashboardTags
// collapses them the same way, first spelling winning. Persisting both would not change who can
// see what (GatewaySession holds its tags in a case-insensitive set) but the constraints column
// would report one grant twice, and a security grant that reads as two is the wrong kind of
// wrong on an audit surface. The other five list fields keep ParseList's verbatim behaviour:
// their globs are matched literally, so near-duplicates there are not necessarily the same rule.
private static IReadOnlyList<string> ParseDashboardTags(string? value)
{
return [.. ParseList(value).Distinct(StringComparer.OrdinalIgnoreCase)];
}
private static IReadOnlyList<string> ParseList(string? value)
{
return (value ?? string.Empty)