feat(security): DashboardTags on API-key constraints; sessions inherit owner tags (SEC-25 groundwork)

Adds a dashboard event-visibility tag to ApiKeyConstraints, riding in the existing
constraints JSON blob so no auth-store schema migration is needed (design
docs/plans/2026-07-10-dashboard-session-acl-tst15.md sections 3/3.1, open call
settled per its own recommendation). The tag is visibility-only: no read, write,
browse, or subscribe path consults it, and HasRead/HasWriteConstraints ignore it.

GatewaySession gains an immutable, ordinal-ignore-case Tags set stamped at
construction from the owning API key, forwarded by MxAccessGatewayService.OpenSession
from the resolved ApiKeyIdentity — never from the wire request, so a client cannot
label its own session with another tenant's tag. ISessionManager gains a tag-carrying
OpenSessionAsync overload whose default implementation forwards to the tagless one, so
an implementation that does not model tags opens an untagged (least visible) session.

apikey create-key gains --dashboard-tags team-a,team-b (repeatable, trimmed,
de-duplicated; an empty segment is rejected rather than dropped) and list-keys prints
the tags column. No enforcement yet — the EventsHub ACL that consumes the tag is a
later change.
This commit is contained in:
Joseph Doherty
2026-08-17 03:58:14 -04:00
parent 9130994736
commit a212e145ac
14 changed files with 477 additions and 10 deletions
@@ -233,7 +233,45 @@ public static class ApiKeyAdminCommandLineParser
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
BrowseSubtrees: GetOptions(options, "browse-subtree"),
ReadAlarmOnly: HasFlag(options, "read-alarm-only"),
ReadHistorizedOnly: HasFlag(options, "read-historized-only"));
ReadHistorizedOnly: HasFlag(options, "read-historized-only"))
{
DashboardTags = ParseDashboardTags(options),
};
}
// --dashboard-tags takes a comma-separated list ("team-a,team-b"); repeating the flag unions
// its values. Segments are trimmed and de-duplicated ordinal-ignore-case, matching how the
// enforcement site compares them. An empty segment is rejected rather than dropped: a stray
// comma otherwise silently persists a grant the operator did not mean to write.
private static IReadOnlyList<string> ParseDashboardTags(Dictionary<string, List<string?>> options)
{
if (!options.TryGetValue("dashboard-tags", out List<string?>? values))
{
return Array.Empty<string>();
}
List<string> tags = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (string? raw in values)
{
foreach (string segment in (raw ?? string.Empty).Split(','))
{
string tag = segment.Trim();
if (tag.Length == 0)
{
throw new FormatException(
"--dashboard-tags must be a comma-separated list of non-empty tags.");
}
if (seen.Add(tag))
{
tags.Add(tag);
}
}
}
return tags.Count == 0 ? Array.Empty<string>() : tags;
}
// Parses the optional --expires value into an absolute UTC expiry. Accepts a relative