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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -884,6 +884,144 @@ public sealed class GatewayOptionsValidatorTests
|
||||
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>
|
||||
[Fact]
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user