f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
156 lines
7.3 KiB
C#
156 lines
7.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tag-intersection implementation of <see cref="IDashboardSessionAcl"/>. Fails closed on
|
|
/// every branch: an unknown session, an empty tag grant, and an untagged session under the
|
|
/// default <see cref="UntaggedSessionVisibility.AdminOnly"/> all deny.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Decision order (first match wins):
|
|
/// </para>
|
|
/// <list type="number">
|
|
/// <item><description>Authenticated caller in <see cref="DashboardRoles.Admin"/> → allow. Admin
|
|
/// already reaches every destructive surface, so event-metadata visibility is strictly weaker.</description></item>
|
|
/// <item><description>Session not present in <see cref="ISessionManager"/> → deny. No subscription
|
|
/// is created for a phantom id.</description></item>
|
|
/// <item><description>Session carries no tags → allow only when
|
|
/// <c>MxGateway:Dashboard:UntaggedSessionVisibility</c> is
|
|
/// <see cref="UntaggedSessionVisibility.AllViewers"/>.</description></item>
|
|
/// <item><description>Otherwise allow iff the session's tags intersect the caller's granted tags
|
|
/// (ordinal-ignore-case).</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// <strong>Where the grant comes from.</strong> The grant is re-resolved on every call
|
|
/// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims
|
|
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
|
|
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
|
|
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> makes at token mint. It deliberately does
|
|
/// NOT trust the <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> 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
|
|
/// <c>GroupToTag</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <strong>Fallback.</strong> A principal carrying no group claims at all falls back to its
|
|
/// stamped tag claims. That arm exists for the hub-token principal: <see cref="HubTokenService"/>
|
|
/// 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
|
|
/// (<see cref="DashboardAuthenticator"/> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <strong>Revocation bounds this produces</strong> (mirrored in
|
|
/// <c>docs/GatewayDashboardDesign.md</c>): a <c>GroupToTag</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// This sits on the <em>subscribe</em> 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 <see cref="GatewaySession.Tags"/>
|
|
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
|
|
/// while the subscription lives.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="sessionManager">Registry the session id is resolved against.</param>
|
|
/// <param name="options">
|
|
/// Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c> and the
|
|
/// <c>Dashboard:GroupToTag</c> map the caller's grant is re-resolved through.
|
|
/// </param>
|
|
public sealed class DashboardSessionAcl(
|
|
ISessionManager sessionManager,
|
|
IOptions<GatewayOptions> options) : IDashboardSessionAcl
|
|
{
|
|
/// <inheritdoc />
|
|
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<string>? groups = null;
|
|
foreach (Claim groupClaim in principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType))
|
|
{
|
|
(groups ??= []).Add(groupClaim.Value);
|
|
}
|
|
|
|
if (groups is not null)
|
|
{
|
|
// 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<string> 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;
|
|
}
|
|
}
|