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
+11 -1
View File
@@ -60,7 +60,11 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
"GroupToRole": { "GroupToRole": {
"GwAdmin": "Admin", "GwAdmin": "Admin",
"GwReader": "Viewer" "GwReader": "Viewer"
} },
"GroupToTag": {
"GwReader": [ "team-a" ]
},
"UntaggedSessionVisibility": "AdminOnly"
}, },
"Protocol": { "Protocol": {
"WorkerProtocolVersion": 1, "WorkerProtocolVersion": 1,
@@ -190,6 +194,8 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. Security-relevant because the per-session hub ACL that would scope a Viewer to specific sessions does not exist yet: with no per-session scoping, this redaction is currently the only thing standing between a low-trust Viewer and other sessions' tag values, so setting this `true` exposes every session's tag values to every authenticated dashboard viewer. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. | | `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. Security-relevant because the per-session hub ACL that would scope a Viewer to specific sessions does not exist yet: with no per-session scoping, this redaction is currently the only thing standing between a low-trust Viewer and other sessions' tag values, so setting this `true` exposes every session's tag values to every authenticated dashboard viewer. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | | `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` (short CN or full DN — leading-RDN match, case-insensitive); values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. |
| `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. |
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. | | `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. |
| `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. | | `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. |
@@ -198,6 +204,10 @@ and `RecentSessionLimit` must be greater than or equal to zero.
`GroupToRole` values are validated at startup; invalid role names fail `GroupToRole` values are validated at startup; invalid role names fail
validation. Emptiness is allowed (a closed deployment that admits no LDAP validation. Emptiness is allowed (a closed deployment that admits no LDAP
users) but practical deployments populate at least one Admin group. users) but practical deployments populate at least one Admin group.
`GroupToTag` is validated for shape only — non-blank group keys, non-null tag
lists, non-blank tags — and is not cross-checked against `GroupToRole`, because
role grants and visibility grants are deliberately separate concerns.
`UntaggedSessionVisibility` must be `AdminOnly` or `AllViewers`.
### Authorization policies ### Authorization policies
@@ -67,4 +67,20 @@ public sealed class DashboardOptions
/// Users with no matching group are rejected at login. /// Users with no matching group are rejected at login.
/// </summary> /// </summary>
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase); 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( AddIfNotPositive(
options.SnapshotIntervalMilliseconds, options.SnapshotIntervalMilliseconds,
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.", "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;
}
}
@@ -884,6 +884,144 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded); Assert.True(result.Succeeded);
} }
/// <summary>Verifies a populated GroupToTag map with well-formed tags passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenGroupToTagWellFormed()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a"],
["TeamBViewers"] = ["team-b", "team-c"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>
/// Verifies GroupToTag is not coupled to GroupToRole: a group that grants a tag
/// but no role (and vice versa) is a legal configuration.
/// </summary>
[Fact]
public void Validate_Succeeds_WhenGroupToTagAndGroupToRoleShareNoGroups()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = "Administrator",
},
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["TeamBViewers"] = ["team-b"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies a blank GroupToTag key (LDAP group name) fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagKeyIsBlank()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[" "] = ["team-a"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:GroupToTag") && f.Contains("non-blank"));
}
/// <summary>Verifies a blank tag entry fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagContainsBlankTag()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a", " "],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("non-blank"));
}
/// <summary>Verifies a null tag list (e.g. <c>"GwViewer": null</c> in JSON) fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagValueIsNull()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = null!,
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("null"));
}
/// <summary>Verifies both defined <see cref="UntaggedSessionVisibility"/> values pass validation.</summary>
/// <param name="visibility">The visibility value under test.</param>
[Theory]
[InlineData(UntaggedSessionVisibility.AdminOnly)]
[InlineData(UntaggedSessionVisibility.AllViewers)]
public void Validate_Succeeds_ForDefinedUntaggedSessionVisibility(UntaggedSessionVisibility visibility)
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions { UntaggedSessionVisibility = visibility });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies an out-of-range <see cref="UntaggedSessionVisibility"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenUntaggedSessionVisibilityUndefined()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions { UntaggedSessionVisibility = (UntaggedSessionVisibility)42 });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:UntaggedSessionVisibility"));
}
/// <summary>Verifies the shipped default for untagged sessions is the strict AdminOnly.</summary>
[Fact]
public void DashboardOptions_UntaggedSessionVisibility_DefaultsToAdminOnly()
{
Assert.Equal(UntaggedSessionVisibility.AdminOnly, new DashboardOptions().UntaggedSessionVisibility);
Assert.Empty(new DashboardOptions().GroupToTag);
}
/// <summary>Verifies plaintext LDAP transport (None) aborts startup in Production.</summary> /// <summary>Verifies plaintext LDAP transport (None) aborts startup in Production.</summary>
[Fact] [Fact]
public void Validate_Fails_WhenLdapTransportNoneInProduction() public void Validate_Fails_WhenLdapTransportNoneInProduction()
@@ -0,0 +1,113 @@
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Tests for <see cref="DashboardGroupTagMapping"/>, the LDAP-group → dashboard
/// visibility-tag grant. Group matching must follow the same rules as
/// <see cref="DashboardGroupRoleMapping"/> (full DN first, leading-RDN fallback,
/// case-insensitive), and the grant is the union across the user's groups.
/// </summary>
public sealed class DashboardGroupTagMappingTests
{
private static Dictionary<string, string[]> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a"],
["TeamBViewers"] = ["team-b", "team-c"],
};
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedTag">The expected single granted tag, or null if no match.</param>
[Theory]
[InlineData("GwViewer", "team-a")]
[InlineData("gwviewer", "team-a")]
[InlineData("ou=GwViewer,ou=groups,dc=zb,dc=local", "team-a")]
[InlineData("OtherGroup", null)]
public void MapGroupsToTags_ResolvesByShortNameAndDistinguishedName(string ldapGroup, string? expectedTag)
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags([ldapGroup], StandardMapping());
if (expectedTag is null)
{
Assert.Empty(tags);
}
else
{
Assert.Equal(expectedTag, Assert.Single(tags));
}
}
/// <summary>Verifies the grant is the union of every matching group's tags.</summary>
[Fact]
public void MapGroupsToTags_MultipleGroups_UnionsTags()
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
["GwViewer", "TeamBViewers"],
StandardMapping());
string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)];
Assert.Equal<string>(["team-a", "team-b", "team-c"], ordered);
}
/// <summary>Verifies an unknown group contributes nothing to a grant its siblings still produce.</summary>
[Fact]
public void MapGroupsToTags_UnknownGroup_ContributesNothing()
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
["GwViewer", "NotInTheMap"],
StandardMapping());
Assert.Equal("team-a", Assert.Single(tags));
}
/// <summary>Verifies the same tag granted by two groups, differing only in case, collapses to one entry.</summary>
[Fact]
public void MapGroupsToTags_DuplicateTagsAcrossGroups_DedupedCaseInsensitively()
{
Dictionary<string, string[]> mapping = new(StringComparer.OrdinalIgnoreCase)
{
["GroupOne"] = ["team-a"],
["GroupTwo"] = ["TEAM-A"],
};
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(["GroupOne", "GroupTwo"], mapping);
Assert.Single(tags);
Assert.Contains("team-a", tags);
Assert.Contains("TEAM-A", tags);
}
/// <summary>Verifies an empty map yields an empty grant — no Viewer sees a tagged session.</summary>
[Fact]
public void MapGroupsToTags_EmptyMapping_ReturnsNoTags()
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
["GwViewer"],
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase));
Assert.Empty(tags);
}
/// <summary>
/// The tag grant is independent of the role map: a group present only in
/// GroupToTag still grants its tags. Asserted here because the two maps are
/// deliberately uncoupled in validation as well.
/// </summary>
[Fact]
public void MapGroupsToTags_GroupAbsentFromRoleMap_StillGrantsTags()
{
Dictionary<string, string> groupToRole = new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
};
IReadOnlyList<string> roles = DashboardGroupRoleMapping.MapGroupsToRoles(["TeamBViewers"], groupToRole);
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(["TeamBViewers"], StandardMapping());
string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)];
Assert.Empty(roles);
Assert.Equal<string>(["team-b", "team-c"], ordered);
}
}