using System.Security.Claims; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Sessions; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Tag-intersection implementation of . Fails closed on /// every branch: an unknown session, an empty tag grant, and an untagged session under the /// default all deny. /// /// /// /// Decision order (first match wins): /// /// /// Authenticated caller in → allow. Admin /// already reaches every destructive surface, so event-metadata visibility is strictly weaker. /// Session not present in → deny. No subscription /// is created for a phantom id. /// Session carries no tags → allow only when /// MxGateway:Dashboard:UntaggedSessionVisibility is /// . /// Otherwise allow iff the session's tags intersect the caller's granted tags /// (ordinal-ignore-case). /// /// /// Where the grant comes from (SEC-41). The grant is re-resolved on every call /// from the caller's claims /// through MxGateway:Dashboard:GroupToTag as it stands in the currently bound options — /// the same single call /// makes at token mint. It deliberately does /// NOT trust the claims /// stamped into the cookie at login: those were resolved under whatever map was bound when the /// user signed in, and the auth cookie survives the gateway restart that applies a /// GroupToTag edit (options bind once at startup; data-protection keys persist). Trusting /// them let a revoked grant live on for the sliding 8-hour cookie life, renewable indefinitely by /// activity, on the one seam — cookie-authenticated — that the 5-minute hub-token bound did not /// cover. /// /// /// Fallback. A principal carrying no group claims at all falls back to its /// stamped tag claims. That arm exists for the hub-token principal: /// re-resolves the grant at mint and rehydrates it as tag claims only — the token payload carries /// no LDAP groups — so re-resolution has already happened for that caller, one token lifetime ago /// at worst. The fallback cannot re-open the cookie hole it replaces, because a cookie principal /// with a tag claim always also carries the group claims it was derived from /// ( stamps both from one group list), and a login that /// yielded no groups yields no tags either. A principal with neither kind of claim — anonymous /// localhost included — is an empty-grant Viewer. /// /// /// Revocation bounds this produces (mirrored in /// docs/GatewayDashboardDesign.md): a GroupToTag entry removed from config takes /// effect on the next subscribe after the restart that rebinds options, on both seams; an LDAP /// group-membership revocation takes effect when the principal is rebuilt — at re-login (bounded /// by the cookie's sliding 8-hour idle life) for the cookie seam, and within one 5-minute token /// lifetime for the hub-token seam; an already-open subscription is never re-checked and lives /// until it drops. /// /// /// This sits on the subscribe path, not the per-event path, and must stay cheap enough to /// keep it there: the only per-call work is a claim scan, the group→tag map lookup, and a /// session-registry lookup. A per-event re-check is deliberately not needed — a joined SignalR /// group and an in-process subscription are both per-session, and /// is immutable for the session's life, so the decision taken at subscribe time cannot go stale /// while the subscription lives. /// /// /// Registry the session id is resolved against. /// /// Gateway options supplying Dashboard:UntaggedSessionVisibility and the /// Dashboard:GroupToTag map the caller's grant is re-resolved through. /// public sealed class DashboardSessionAcl( ISessionManager sessionManager, IOptions options) : IDashboardSessionAcl { /// public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) { if (principal is null || string.IsNullOrWhiteSpace(sessionId)) { return false; } if (principal.Identity?.IsAuthenticated == true && principal.IsInRole(DashboardRoles.Admin)) { return true; } if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session)) { return false; } DashboardOptions dashboard = options.Value.Dashboard; if (session.Tags.Count == 0) { return dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers; } return GrantIntersectsSessionTags(principal, session, dashboard); } private static bool GrantIntersectsSessionTags( ClaimsPrincipal principal, GatewaySession session, DashboardOptions dashboard) { // Collected rather than streamed because MapGroupsToTags takes the whole group list; // the list stays null (no allocation) for the hub-token principal, which carries none. List? groups = null; foreach (Claim groupClaim in principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)) { (groups ??= []).Add(groupClaim.Value); } if (groups is not null) { // SEC-41: current options, not the login-time snapshot. MapGroupsToTags returns an // OrdinalIgnoreCase set, so probing it with the session's tags carries the // case-insensitive comparison in the same direction the claim scan below does. IReadOnlySet granted = DashboardGroupTagMapping.MapGroupsToTags( groups, dashboard.GroupToTag); foreach (string sessionTag in session.Tags) { if (granted.Contains(sessionTag)) { return true; } } return false; } // No group claims: the hub-token arm documented on the class. Session.Tags is an // ordinal-ignore-case set, so the containment test carries the comparison; scanning the // claims (rather than materializing the grant) keeps this allocation-free beyond the // claim enumerator. foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)) { if (session.Tags.Contains(tagClaim.Value)) { return true; } } return false; } }