From c79aaaf9eb0b9753e3f2a28d2e48a15269c2c58a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 17 Aug 2026 03:46:56 -0400 Subject: [PATCH] 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. --- docs/GatewayConfiguration.md | 12 +- .../Configuration/DashboardOptions.cs | 16 ++ .../Configuration/GatewayOptionsValidator.cs | 30 ++++ .../UntaggedSessionVisibility.cs | 22 +++ .../Dashboard/DashboardGroupTagMapping.cs | 59 ++++++++ .../GatewayOptionsValidatorTests.cs | 138 ++++++++++++++++++ .../DashboardGroupTagMappingTests.cs | 113 ++++++++++++++ 7 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 210454f..378f955 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -60,7 +60,11 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. "GroupToRole": { "GwAdmin": "Admin", "GwReader": "Viewer" - } + }, + "GroupToTag": { + "GwReader": [ "team-a" ] + }, + "UntaggedSessionVisibility": "AdminOnly" }, "Protocol": { "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: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: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: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 validation. Emptiness is allowed (a closed deployment that admits no LDAP 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 diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs index a0f888c..b3d995d 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs @@ -67,4 +67,20 @@ public sealed class DashboardOptions /// Users with no matching group are rejected at login. /// public Dictionary GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 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 + /// — a group may appear in either map, both, or + /// neither. Visibility only: tags never gate data access. + /// + public Dictionary GroupToTag { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Who may observe a session whose owning API key carries no dashboard tags. + /// Defaults to + /// so an upgrade tightens rather than loosens. + /// + public UntaggedSessionVisibility UntaggedSessionVisibility { get; init; } = UntaggedSessionVisibility.AdminOnly; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index eadeea3..e9a7b1f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -410,6 +410,36 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase 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.", diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs new file mode 100644 index 0000000..342a111 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/UntaggedSessionVisibility.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.MxGateway.Server.Configuration; + +/// +/// 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. +/// +public enum UntaggedSessionVisibility +{ + /// + /// Default. An untagged session is visible only to a dashboard Administrator. + /// Fails closed: a deployment that has not populated + /// shows Viewers nothing. + /// + AdminOnly, + + /// + /// An untagged session is visible to every dashboard Viewer. Opt-in for a + /// genuinely single-tenant deployment that wants the pre-ACL behaviour. + /// + AllViewers +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs new file mode 100644 index 0000000..dc54fd7 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupTagMapping.cs @@ -0,0 +1,59 @@ +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Single source of truth for mapping a user's LDAP groups to the dashboard +/// visibility tags they are granted (MxGateway:Dashboard:GroupToTag). +/// Sibling of 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. +/// +internal static class DashboardGroupTagMapping +{ + /// + /// 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". + /// + /// The collection of LDAP groups the user belongs to. + /// The mapping from group names to granted tags. + /// The distinct tags granted across all of the user's groups. + internal static IReadOnlySet MapGroupsToTags( + IEnumerable groups, + IReadOnlyDictionary groupToTag) + { + HashSet 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; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs index 2878517..89e4581 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs @@ -884,6 +884,144 @@ public sealed class GatewayOptionsValidatorTests Assert.True(result.Succeeded); } + /// Verifies a populated GroupToTag map with well-formed tags passes validation. + [Fact] + public void Validate_Succeeds_WhenGroupToTagWellFormed() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwViewer"] = ["team-a"], + ["TeamBViewers"] = ["team-b", "team-c"], + }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + /// + /// Verifies GroupToTag is not coupled to GroupToRole: a group that grants a tag + /// but no role (and vice versa) is a legal configuration. + /// + [Fact] + public void Validate_Succeeds_WhenGroupToTagAndGroupToRoleShareNoGroups() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = "Administrator", + }, + GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["TeamBViewers"] = ["team-b"], + }, + }); + ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options); + Assert.True(result.Succeeded); + } + + /// Verifies a blank GroupToTag key (LDAP group name) fails validation. + [Fact] + public void Validate_Fails_WhenGroupToTagKeyIsBlank() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(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")); + } + + /// Verifies a blank tag entry fails validation. + [Fact] + public void Validate_Fails_WhenGroupToTagContainsBlankTag() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(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")); + } + + /// Verifies a null tag list (e.g. "GwViewer": null in JSON) fails validation. + [Fact] + public void Validate_Fails_WhenGroupToTagValueIsNull() + { + GatewayOptions options = CloneWithDashboard( + ValidOptions(), + new DashboardOptions + { + GroupToTag = new Dictionary(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")); + } + + /// Verifies both defined values pass validation. + /// The visibility value under test. + [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); + } + + /// Verifies an out-of-range fails validation. + [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")); + } + + /// Verifies the shipped default for untagged sessions is the strict AdminOnly. + [Fact] + public void DashboardOptions_UntaggedSessionVisibility_DefaultsToAdminOnly() + { + Assert.Equal(UntaggedSessionVisibility.AdminOnly, new DashboardOptions().UntaggedSessionVisibility); + Assert.Empty(new DashboardOptions().GroupToTag); + } + /// Verifies plaintext LDAP transport (None) aborts startup in Production. [Fact] public void Validate_Fails_WhenLdapTransportNoneInProduction() diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs new file mode 100644 index 0000000..d547deb --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupTagMappingTests.cs @@ -0,0 +1,113 @@ +using ZB.MOM.WW.MxGateway.Server.Dashboard; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +/// +/// Tests for , the LDAP-group → dashboard +/// visibility-tag grant. Group matching must follow the same rules as +/// (full DN first, leading-RDN fallback, +/// case-insensitive), and the grant is the union across the user's groups. +/// +public sealed class DashboardGroupTagMappingTests +{ + private static Dictionary StandardMapping() => new(StringComparer.OrdinalIgnoreCase) + { + ["GwViewer"] = ["team-a"], + ["TeamBViewers"] = ["team-b", "team-c"], + }; + + /// Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty. + /// The LDAP group name or distinguished name. + /// The expected single granted tag, or null if no match. + [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 tags = DashboardGroupTagMapping.MapGroupsToTags([ldapGroup], StandardMapping()); + + if (expectedTag is null) + { + Assert.Empty(tags); + } + else + { + Assert.Equal(expectedTag, Assert.Single(tags)); + } + } + + /// Verifies the grant is the union of every matching group's tags. + [Fact] + public void MapGroupsToTags_MultipleGroups_UnionsTags() + { + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags( + ["GwViewer", "TeamBViewers"], + StandardMapping()); + + string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)]; + Assert.Equal(["team-a", "team-b", "team-c"], ordered); + } + + /// Verifies an unknown group contributes nothing to a grant its siblings still produce. + [Fact] + public void MapGroupsToTags_UnknownGroup_ContributesNothing() + { + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags( + ["GwViewer", "NotInTheMap"], + StandardMapping()); + + Assert.Equal("team-a", Assert.Single(tags)); + } + + /// Verifies the same tag granted by two groups, differing only in case, collapses to one entry. + [Fact] + public void MapGroupsToTags_DuplicateTagsAcrossGroups_DedupedCaseInsensitively() + { + Dictionary mapping = new(StringComparer.OrdinalIgnoreCase) + { + ["GroupOne"] = ["team-a"], + ["GroupTwo"] = ["TEAM-A"], + }; + + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags(["GroupOne", "GroupTwo"], mapping); + + Assert.Single(tags); + Assert.Contains("team-a", tags); + Assert.Contains("TEAM-A", tags); + } + + /// Verifies an empty map yields an empty grant — no Viewer sees a tagged session. + [Fact] + public void MapGroupsToTags_EmptyMapping_ReturnsNoTags() + { + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags( + ["GwViewer"], + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + Assert.Empty(tags); + } + + /// + /// 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. + /// + [Fact] + public void MapGroupsToTags_GroupAbsentFromRoleMap_StillGrantsTags() + { + Dictionary groupToRole = new(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = DashboardRoles.Admin, + }; + + IReadOnlyList roles = DashboardGroupRoleMapping.MapGroupsToRoles(["TeamBViewers"], groupToRole); + IReadOnlySet tags = DashboardGroupTagMapping.MapGroupsToTags(["TeamBViewers"], StandardMapping()); + + string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)]; + + Assert.Empty(roles); + Assert.Equal(["team-b", "team-c"], ordered); + } +}