2ef7b93761
Committed together because the two changes share the same doc paragraphs (the ACL section of GatewayDashboardDesign.md, the GroupToTag / AllowAnonymousLocalhost rows of GatewayConfiguration.md, and CLAUDE.md's authentication paragraph), and splitting them would leave one of the two describing behaviour that does not yet exist. SEC-41 — tag grants no longer trusted from login-stamped claims --------------------------------------------------------------- DashboardSessionAcl read the zb:dashboardtag claims stamped into the cookie at login. GatewayOptions binds once at startup, so applying a GroupToTag edit needs a restart — but the restart does not clear the auth cookie (data-protection keys persist), so a browser returning afterwards re-authenticated with tag claims resolved under the OLD map. A revoked grant therefore survived on the cookie seam for the sliding 8-hour idle life, renewable indefinitely by activity, while the hub-token seam had no such gap because its mint re-resolves. CanViewSession now re-resolves the grant on every subscribe from the caller's mxgateway:ldap_group claims through DashboardGroupTagMapping.MapGroupsToTags against the currently bound map — the same one call HubTokenService.Issue makes. Fallback (documented on the class and in the design doc): a principal carrying NO group claims falls back to its stamped tag claims. That arm exists for the hub-token principal — HubTokenService.Validate rehydrates roles and tags but not groups, so re-resolution already happened at mint, one lifetime ago at worst. It cannot re-open the cookie hole: DashboardAuthenticator stamps group and tag claims from one group list, so a cookie principal holding a tag claim always holds the groups it came from, and a login yielding no groups yields no tags. A principal with neither kind of claim stays an empty-grant Viewer. SEC-40 — the alarm feed requires a real authenticated dashboard role -------------------------------------------------------------------- AlarmsHub sat behind HubClientsPolicy, which the read-only environment bypasses satisfy, so an anonymous-localhost caller or a Mode=Disabled deployment received galaxy-wide alarm metadata — alarm reference (a full tag path), state, severity, category, area, acknowledging operator — for the whole subscription expression. Decided: tighten the gate, keep the scope. New AuthenticatedDashboardRoleRequirement + its handler, and a MxGateway.Dashboard.Alarms policy built from it. Because DashboardAuthorizationHandler is typed to DashboardAuthorizationRequirement, it is never invoked for the new type, so neither Authentication:Mode=Disabled nor anonymous localhost can satisfy the policy — the same structural device the mounted Secrets UI already relies on. The policy keeps both schemes so external hub clients can still authenticate with a bearer token, and DisableLogin auto-login is unaffected (it mints a genuinely authenticated both-roles principal). The /alarms page carries the same policy, not just the hub: it reads the same central monitor in process, so gating only the hub would have left the disclosure one navigation away. The side rail hides the Alarms link for callers the policy denies, matching the Secrets link precedent. AlarmsHubPublisher is unchanged and stays unfiltered by design — audience is decided at hub admission, and the monitor carries no per-session provenance to narrow by. Alarm metadata deliberately remains galaxy-wide for authenticated Viewers: an AlarmFeedMessage has no session id or owning key for the SEC-25 tag intersection to work against, and equating a visibility tag with an alarm-reference prefix would turn GroupToTag into the data-access constraint it explicitly is not. Tests ----- DashboardSessionAclTests: the SEC-41 regression — a cookie-style principal whose stale tag claim is no longer granted by any of its groups under the current map is denied; plus the allowing direction (including with no stamped tag at all, which proves the decision comes from the groups), the ignore-case corner, group claims that map to nothing, and the hub-token fallback asserted against an EMPTY map so dropping the fallback would fail loudly. AlarmFeedAuthorizationTests (new): both handlers run together in the most permissive environment (Mode=Disabled + loopback + AllowAnonymousLocalhost) do not satisfy the alarm requirement, with the bypassable requirement as the control so the negative cannot pass because the bypass was misconfigured; authenticated Viewer/Admin do satisfy it; an unauthenticated role claim does not; the registered policy carries the non-bypassable requirement and both schemes; and both readers — the hub type's [Authorize] and the /alarms endpoint metadata — carry it. SecretsNavRenderTests: registers the new policy (an unregistered policy name throws rather than denying in an AuthorizeView) and gains the Alarms-link negative/control pair. Verified: dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx (zero warnings); filtered run of the touched classes — 92 passed, 0 failed.
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 (SEC-41).</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)
|
|
{
|
|
// 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<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;
|
|
}
|
|
}
|