using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Sessions; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; /// /// Covers , the single decision both dashboard subscribe seams /// consult (SEC-25 / TST-15). /// /// /// Every branch is asserted in its denying direction as well as its allowing one, because the /// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted /// cannot distinguish a working gate from no gate at all. /// public sealed class DashboardSessionAclTests { private const string TaggedSessionId = "session-tagged"; private const string UntaggedSessionId = "session-untagged"; /// An Administrator bypasses the tag check entirely, including for a tag they hold none of. [Fact] public void CanViewSession_Administrator_BypassesTagCheck() { DashboardSessionAcl acl = CreateAcl(); Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId)); Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId)); } /// /// The admin bypass is what keeps Dashboard:DisableLogin auto-login (which stamps both /// roles and no tags) working exactly as before this change. /// [Fact] public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed() { DashboardSessionAcl acl = CreateAcl(); Assert.True(acl.CanViewSession( Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]), TaggedSessionId)); } /// /// The decision table's order is load-bearing at exactly one corner: an Administrator naming /// a session id the registry does not have is ALLOWED, because the admin bypass is checked /// before the lookup. Pinned deliberately — reordering the two checks (a plausible "look the /// session up first, it reads better" refactor) would flip this to a denial and quietly change /// what an Administrator's hub join does for a session that closed a moment ago. /// [Fact] public void CanViewSession_AdministratorAndUnknownSession_Allowed() { DashboardSessionAcl acl = CreateAcl(); Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), "session-does-not-exist")); } /// /// An unknown session id is denied for a non-Admin even when they hold every configured tag: /// no subscription is created for a session the registry does not have. The Administrator /// counterpart above is the deliberate exception. /// [Fact] public void CanViewSession_UnknownSession_Denied() { DashboardSessionAcl acl = CreateAcl(); Assert.False(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]), "session-does-not-exist")); } /// A blank session id is denied without consulting anything. [Theory] [InlineData("")] [InlineData(" ")] public void CanViewSession_BlankSessionId_Denied(string sessionId) { DashboardSessionAcl acl = CreateAcl(); Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId)); } /// A null principal denies — the fail-closed reading of an unauthenticated hub context. [Fact] public void CanViewSession_NullPrincipal_Denied() { DashboardSessionAcl acl = CreateAcl(); Assert.False(acl.CanViewSession(null, UntaggedSessionId)); } /// /// Untagged sessions follow Dashboard:UntaggedSessionVisibility: hidden from Viewers /// under the shipped default, visible under /// the opt-in . /// /// The configured untagged-session visibility. /// Whether a tagless Viewer may observe the untagged session. [Theory] [InlineData(UntaggedSessionVisibility.AdminOnly, false)] [InlineData(UntaggedSessionVisibility.AllViewers, true)] public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility( UntaggedSessionVisibility visibility, bool expected) { DashboardSessionAcl acl = CreateAcl(visibility); Assert.Equal( expected, acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId)); } /// /// A Viewer whose grant intersects the session's tags is allowed; the comparison is /// ordinal-ignore-case, matching the session's tag set and the config map. /// /// The single tag the Viewer holds. [Theory] [InlineData("team-a")] [InlineData("TEAM-A")] public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag) { DashboardSessionAcl acl = CreateAcl(); Assert.True(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]), TaggedSessionId)); } /// A Viewer holding only another tenant's tag is denied — the load-bearing negative. [Fact] public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied() { DashboardSessionAcl acl = CreateAcl(); Assert.False(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]), TaggedSessionId)); } /// /// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of /// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted /// into . /// [Fact] public void CanViewSession_NoTagClaims_IsEmptyGrantViewer() { ClaimsPrincipal anonymous = new(new ClaimsIdentity()); Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId)); Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId)); Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId)); } /// /// An unauthenticated principal that nonetheless carries an Administrator role claim does not /// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the /// dashboard (DashboardSessionAdminService.CanManage). /// [Fact] public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass() { // No authentication type => IsAuthenticated is false. ClaimsPrincipal principal = new(new ClaimsIdentity( [new Claim(ClaimTypes.Role, DashboardRoles.Admin)], authenticationType: null, nameType: ClaimTypes.Name, roleType: ClaimTypes.Role)); Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId)); } /// /// SEC-41, the load-bearing regression: a cookie-style principal whose tag claim was stamped /// at login under an older GroupToTag map is DENIED once that map no longer grants the /// tag to any group the principal belongs to. The auth cookie survives the restart that /// rebinds options, so trusting the stamped claim let a config-revoked grant live on for the /// sliding 8-hour cookie life; the ACL now re-resolves from the group claims the same /// principal carries, against the currently bound map. /// [Fact] public void CanViewSession_StampedTagClaimRevokedFromCurrentGroupToTag_Denied() { // Current config grants team-a to nobody the caller belongs to: "plant-ops" was the group // whose entry was removed, and the surviving entry is for an unrelated group. DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["other-team"] = ["team-b"] }); ClaimsPrincipal stale = Principal( roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: ["plant-ops"]); Assert.False(acl.CanViewSession(stale, TaggedSessionId)); } /// /// The re-resolution's allowing direction: the same principal is allowed when the current map /// still grants the session's tag to one of its groups — and is allowed even when it carries /// NO stamped tag claim at all, which is what proves the decision comes from the group claims /// rather than from the stamped ones. /// [Fact] public void CanViewSession_GroupClaimGrantsTagUnderCurrentMap_Allowed() { DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["plant-ops"] = ["team-a"] }); Assert.True(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: ["plant-ops"]), TaggedSessionId)); Assert.True(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: null, groups: ["plant-ops"]), TaggedSessionId)); } /// /// Re-resolution is ordinal-ignore-case on both sides — the group key against the map (which /// owns) and the resolved tag against the session's tag /// set. Pinned because the pre-SEC-41 claim scan got the second comparison from /// Session.Tags's own comparer, and the re-resolution arm probes the grant set in the /// opposite direction. /// [Fact] public void CanViewSession_GroupAndTagCasingDiffer_Allowed() { DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["PLANT-OPS"] = ["TEAM-A"] }); Assert.True(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: null, groups: ["plant-ops"]), TaggedSessionId)); } /// /// A principal carrying group claims that map to nothing is an empty-grant Viewer, even though /// it holds a stamped tag claim: once there is a group list to re-resolve from, the stamped /// claims are not consulted at all. This is the arm that closes the revocation gap for a user /// whose LDAP groups changed shape rather than whose config entry was removed. /// [Fact] public void CanViewSession_GroupClaimsPresentButUnmapped_IgnoresStampedTagClaim() { DashboardSessionAcl acl = CreateAcl(groupToTag: []); Assert.False(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: ["plant-ops"]), TaggedSessionId)); } /// /// The documented fallback: a hub-token principal carries the tags /// re-resolved at mint but no LDAP group claims to re-resolve from, so its stamped tag claims /// are honoured. Asserted with an EMPTY current map, which is the state that would deny if the /// fallback were ever dropped — and would silently break every token-authenticated hub join. /// [Fact] public void CanViewSession_HubTokenStylePrincipalWithNoGroupClaims_UsesStampedTags() { DashboardSessionAcl acl = CreateAcl(groupToTag: []); Assert.True(acl.CanViewSession( Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: null), TaggedSessionId)); } private static DashboardSessionAcl CreateAcl( UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly, Dictionary? groupToTag = null) { GatewayOptions options = new() { Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility, // Mirrors the binder's comparer so a test's group key behaves like a config one. GroupToTag = groupToTag is null ? new Dictionary(StringComparer.OrdinalIgnoreCase) : new Dictionary(groupToTag, StringComparer.OrdinalIgnoreCase), }, }; return new DashboardSessionAcl( new TwoSessionManager( CreateSession(TaggedSessionId, ["team-a"]), CreateSession(UntaggedSessionId, tags: null)), Options.Create(options)); } /// /// Builds a dashboard principal. is the SEC-41 discriminator: a /// principal WITH group claims is re-resolved against the current map (the cookie seam), one /// WITHOUT falls back to its stamped tag claims (the hub-token seam). Existing cases pass /// tags only, so they continue to exercise the fallback arm exactly as before. /// /// Role claims to stamp. /// Login-stamped visibility tag claims, if any. /// LDAP group claims, if any. /// An authenticated principal carrying the requested claims. private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null, string[]? groups = null) { List claims = [new Claim(ClaimTypes.Name, "viewer-user")]; claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); claims.AddRange((tags ?? []).Select(tag => new Claim( DashboardAuthenticationDefaults.DashboardTagClaimType, tag))); claims.AddRange((groups ?? []).Select(group => new Claim( DashboardAuthenticationDefaults.LdapGroupClaimType, group))); return new ClaimsPrincipal(new ClaimsIdentity( claims, authenticationType: "test", nameType: ClaimTypes.Name, roleType: ClaimTypes.Role)); } private static GatewaySession CreateSession(string sessionId, string[]? tags) { return new GatewaySession( sessionId: sessionId, backendName: "backend", pipeName: $"pipe-{sessionId}", nonce: "nonce", clientIdentity: "client", ownerKeyId: "key-1", clientSessionName: "client-session", clientCorrelationId: "correlation", commandTimeout: TimeSpan.FromSeconds(5), startupTimeout: TimeSpan.FromSeconds(5), shutdownTimeout: TimeSpan.FromSeconds(5), leaseDuration: TimeSpan.FromMinutes(30), openedAt: DateTimeOffset.UnixEpoch, ownerDashboardTags: tags); } /// Registry double serving exactly the two sessions the ACL cases need. private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager { /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, string? ownerKeyId, CancellationToken cancellationToken) => Task.FromResult(tagged); /// public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) { session = sessionId switch { TaggedSessionId => tagged, UntaggedSessionId => untagged, _ => null, }; return session is not null; } /// public Task InvokeAsync( string sessionId, WorkerCommand command, CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); /// public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); /// public Task KillWorkerAsync( string sessionId, string reason, CancellationToken cancellationToken) => Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(0); /// public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; } }