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;
}
}