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:
+39
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user