feat(dashboard): GroupToTag / UntaggedSessionVisibility config (SEC-25)

Groundwork for the per-session dashboard event ACL (docs/plans/2026-07-10-dashboard-session-acl-tst15.md 3.2): a dashboard group can now grant visibility tags, and untagged sessions default to AdminOnly. Enforcement lands with the EventsHub ACL; nothing consumes the grant yet.

GroupToTag is deliberately uncoupled from GroupToRole - a group may appear in either map, both, or neither - and is validated for shape only. Tags gate dashboard event visibility, never data access.
This commit is contained in:
Joseph Doherty
2026-08-17 03:46:56 -04:00
parent fa9eb0c0b4
commit c79aaaf9eb
7 changed files with 389 additions and 1 deletions
@@ -67,4 +67,20 @@ public sealed class DashboardOptions
/// Users with no matching group are rejected at login.
/// </summary>
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// LDAP group → dashboard visibility tags. A dashboard user's granted tag set
/// is the union over the groups they belong to; a session is observable on the
/// events hub when its tags intersect that grant. Independent of
/// <see cref="GroupToRole"/> — a group may appear in either map, both, or
/// neither. Visibility only: tags never gate data access.
/// </summary>
public Dictionary<string, string[]> GroupToTag { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Who may observe a session whose owning API key carries no dashboard tags.
/// Defaults to <see cref="Configuration.UntaggedSessionVisibility.AdminOnly"/>
/// so an upgrade tightens rather than loosens.
/// </summary>
public UntaggedSessionVisibility UntaggedSessionVisibility { get; init; } = UntaggedSessionVisibility.AdminOnly;
}
@@ -410,6 +410,36 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
}
}
// GroupToTag is validated for shape only, and independently of GroupToRole:
// a group may grant a role, a tag, both, or neither. An empty map is legal —
// it yields Viewers with no tag grant, which (under the default AdminOnly)
// means they observe no session's events. That is the fail-closed posture.
foreach (KeyValuePair<string, string[]> entry in options.GroupToTag)
{
if (string.IsNullOrWhiteSpace(entry.Key))
{
builder.Add("MxGateway:Dashboard:GroupToTag keys (LDAP group names) must be non-blank.");
}
if (entry.Value is null)
{
builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] must be a list of tags, not null.");
continue;
}
if (Array.Exists(entry.Value, string.IsNullOrWhiteSpace))
{
builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] tags must be non-blank.");
}
}
if (!Enum.IsDefined(options.UntaggedSessionVisibility))
{
builder.Add(
$"MxGateway:Dashboard:UntaggedSessionVisibility must be '{nameof(UntaggedSessionVisibility.AdminOnly)}' "
+ $"or '{nameof(UntaggedSessionVisibility.AllViewers)}'.");
}
AddIfNotPositive(
options.SnapshotIntervalMilliseconds,
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Who may observe the dashboard event stream of a session that carries no
/// dashboard tags. Tags gate dashboard event VISIBILITY only; they never widen
/// or narrow data access.
/// </summary>
public enum UntaggedSessionVisibility
{
/// <summary>
/// Default. An untagged session is visible only to a dashboard Administrator.
/// Fails closed: a deployment that has not populated
/// <see cref="DashboardOptions.GroupToTag"/> shows Viewers nothing.
/// </summary>
AdminOnly,
/// <summary>
/// An untagged session is visible to every dashboard Viewer. Opt-in for a
/// genuinely single-tenant deployment that wants the pre-ACL behaviour.
/// </summary>
AllViewers
}
@@ -0,0 +1,59 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Single source of truth for mapping a user's LDAP groups to the dashboard
/// visibility tags they are granted (<c>MxGateway:Dashboard:GroupToTag</c>).
/// Sibling of <see cref="DashboardGroupRoleMapping"/> and deliberately follows
/// the same group-matching rules (full DN first, leading-RDN fallback,
/// case-insensitive) so operators write one kind of group key for both maps.
/// Tags gate dashboard event VISIBILITY only; they are never a data-access
/// constraint.
/// </summary>
internal static class DashboardGroupTagMapping
{
/// <summary>
/// Maps the user's LDAP groups to the union of the tags those groups grant.
/// A group with no entry in the map contributes nothing; duplicate tags
/// across groups collapse (case-insensitively). Returns an empty set when no
/// group matches — an empty grant, which the ACL treats as "sees no tagged
/// session".
/// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
/// <param name="groupToTag">The mapping from group names to granted tags.</param>
/// <returns>The distinct tags granted across all of the user's groups.</returns>
internal static IReadOnlySet<string> MapGroupsToTags(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string[]> groupToTag)
{
HashSet<string> tags = new(StringComparer.OrdinalIgnoreCase);
if (groupToTag.Count == 0)
{
return tags;
}
foreach (string group in groups)
{
string normalizedGroup = group.Trim();
if (!groupToTag.TryGetValue(normalizedGroup, out string[]? granted)
&& !groupToTag.TryGetValue(
DashboardGroupRoleMapping.ExtractFirstRdnValue(normalizedGroup),
out granted))
{
continue;
}
if (granted is null)
{
continue;
}
foreach (string tag in granted)
{
tags.Add(tag);
}
}
return tags;
}
}