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:
@@ -143,8 +143,13 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
|
||||
string expiry = key.ExpiresUtc is { } expires
|
||||
? expires.ToUniversalTime().ToString("u", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
// Dashboard tags are operator-facing labels, not key material, so they are safe to
|
||||
// print alongside the scopes; "-" keeps the column aligned for an untagged key.
|
||||
string dashboardTags = key.Constraints.DashboardTags.Count > 0
|
||||
? string.Join(',', key.Constraints.DashboardTags)
|
||||
: "-";
|
||||
await output.WriteLineAsync(
|
||||
$"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}")
|
||||
$"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}\t{dashboardTags}")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -22,6 +22,11 @@ public static class ApiKeyConstraintSerializer
|
||||
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
|
||||
/// <param name="json">The JSON string to deserialize.</param>
|
||||
/// <returns>The deserialized constraints, or <see cref="ApiKeyConstraints.Empty"/> when <paramref name="json"/> is null/whitespace.</returns>
|
||||
/// <remarks>
|
||||
/// Members absent from the JSON take their default: rows persisted before
|
||||
/// <see cref="ApiKeyConstraints.DashboardTags"/> existed carry no <c>dashboard_tags</c>
|
||||
/// member and deserialize to an untagged key, unchanged in every other respect.
|
||||
/// </remarks>
|
||||
public static ApiKeyConstraints Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
|
||||
@@ -10,6 +10,38 @@ public sealed record ApiKeyConstraints(
|
||||
bool ReadAlarmOnly,
|
||||
bool ReadHistorizedOnly)
|
||||
{
|
||||
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dashboard event-visibility tags granted to this key (SEC-25).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is <em>dashboard event-visibility only</em>. It is <strong>never</strong> a
|
||||
/// data-access constraint: no read, write, browse, or subscribe path consults it, and
|
||||
/// adding a tag neither widens nor narrows what the key may read or write. Sessions
|
||||
/// opened by the key inherit these tags (<c>GatewaySession.Tags</c>), and a dashboard
|
||||
/// Viewer may observe a session's mirrored event metadata only when their granted tags
|
||||
/// intersect the session's. It rides in the same serialized constraints blob purely to
|
||||
/// avoid an auth-store schema migration — see
|
||||
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c> §3.1.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tag values are stored exactly as supplied; comparisons are ordinal-ignore-case at the
|
||||
/// enforcement site, so <c>Team-A</c> and <c>team-a</c> name the same tag. An empty list
|
||||
/// means untagged.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IReadOnlyList<string> DashboardTags
|
||||
{
|
||||
get => _dashboardTags;
|
||||
|
||||
// Defensive copy: the tag set is a security-relevant grant, so the record must not alias a
|
||||
// caller-owned list that could be mutated after construction. A null or empty value (an old
|
||||
// persisted row has no dashboard_tags member at all) normalizes to untagged.
|
||||
init => _dashboardTags = value is { Count: > 0 } ? [.. value] : Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>Gets an empty constraints instance with no restrictions.</summary>
|
||||
public static ApiKeyConstraints Empty { get; } = new(
|
||||
ReadSubtrees: Array.Empty<string>(),
|
||||
@@ -22,6 +54,11 @@ public sealed record ApiKeyConstraints(
|
||||
ReadHistorizedOnly: false);
|
||||
|
||||
/// <summary>Gets a value indicating whether the constraints are empty (no restrictions).</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="DashboardTags"/> counts here even though it restricts nothing: an empty
|
||||
/// instance is not persisted at all (<c>ApiKeyConstraintSerializer.Serialize</c> returns
|
||||
/// null), so a key whose only per-key policy is a dashboard tag must still round-trip.
|
||||
/// </remarks>
|
||||
public bool IsEmpty =>
|
||||
ReadSubtrees.Count == 0
|
||||
&& WriteSubtrees.Count == 0
|
||||
@@ -30,7 +67,8 @@ public sealed record ApiKeyConstraints(
|
||||
&& MaxWriteClassification is null
|
||||
&& BrowseSubtrees.Count == 0
|
||||
&& !ReadAlarmOnly
|
||||
&& !ReadHistorizedOnly;
|
||||
&& !ReadHistorizedOnly
|
||||
&& DashboardTags.Count == 0;
|
||||
|
||||
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
|
||||
public bool HasReadConstraints =>
|
||||
|
||||
Reference in New Issue
Block a user