Merge fix/c3-dashboard-acl: SEC-41 ACL re-resolves tag grants from group claims, SEC-40 alarms hub/page behind non-bypassable authenticated-role policy, GWC-38 browse scope fails closed without identity under ApiKey mode
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: succeeds only when the caller
|
||||
/// is authenticated AND holds one of the requirement's roles. It reads no options at all, which
|
||||
/// is the point — there is no configuration value that can turn this into a bypass (SEC-40).
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedDashboardRoleHandler
|
||||
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
AuthenticatedDashboardRoleRequirement requirement)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
foreach (string role in requirement.RequiredRoles)
|
||||
{
|
||||
if (context.User.IsInRole(role))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Requirement satisfied ONLY by an authenticated principal holding one of
|
||||
/// <see cref="RequiredRoles"/> as a real role claim. Unlike
|
||||
/// <see cref="DashboardAuthorizationRequirement"/> there is no environment bypass: it is a
|
||||
/// distinct requirement type, so <see cref="DashboardAuthorizationHandler"/> —
|
||||
/// an <c>AuthorizationHandler<DashboardAuthorizationRequirement></c> — is never invoked for
|
||||
/// it and its <c>Authentication:Mode=Disabled</c> / anonymous-localhost arms cannot reach it.
|
||||
/// The type distinction is the enforcement; adding a bypass arm to the other handler cannot leak
|
||||
/// into this one by accident.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Introduced for SEC-40 to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
|
||||
/// which is gateway-central and session-less and therefore carries no per-session provenance the
|
||||
/// SEC-25 tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
|
||||
/// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path),
|
||||
/// state, severity, category, area, acknowledging operator — away from the read-only environment
|
||||
/// bypasses, which exist for local development and unauthenticated deployments rather than as a
|
||||
/// grant of plant-wide visibility. The same pattern the shared Secrets UI relies on: a policy the
|
||||
/// dashboard bypass handler cannot satisfy because the requirement type differs.
|
||||
/// </remarks>
|
||||
/// <param name="requiredRoles">Roles any one of which satisfies the requirement.</param>
|
||||
public sealed class AuthenticatedDashboardRoleRequirement(IReadOnlyList<string> requiredRoles)
|
||||
: IAuthorizationRequirement
|
||||
{
|
||||
/// <summary>Gets the roles, any one of which satisfies this requirement.</summary>
|
||||
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the requirement satisfied by an authenticated caller holding either dashboard role.
|
||||
/// Same role set as <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>; the
|
||||
/// difference is entirely that no environment bypass applies.
|
||||
/// </summary>
|
||||
public static AuthenticatedDashboardRoleRequirement AnyDashboardRole { get; } =
|
||||
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
|
||||
}
|
||||
@@ -12,7 +12,14 @@
|
||||
<NavRailItem Href="/sessions" Text="Sessions" />
|
||||
<NavRailItem Href="/workers" Text="Workers" />
|
||||
<NavRailItem Href="/events" Text="Events" />
|
||||
<NavRailItem Href="/alarms" Text="Alarms" />
|
||||
@* Gated on the SAME policy the /alarms page and AlarmsHub enforce (SEC-40), for the
|
||||
same reason the Secrets link below is: the page denies an environment-bypass
|
||||
caller outright, so an ungated link would be a dead end. *@
|
||||
<AuthorizeView Policy="@DashboardAuthenticationDefaults.AlarmsPolicy">
|
||||
<Authorized>
|
||||
<NavRailItem Href="/alarms" Text="Alarms" />
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Galaxy" Key="galaxy">
|
||||
<NavRailItem Href="/galaxy" Text="Repository" />
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
@page "/alarms"
|
||||
@implements IAsyncDisposable
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using ZB.MOM.WW.MxGateway.Server.Alarms
|
||||
@* SEC-40: the alarm feed needs a real authenticated dashboard role, not the read-only
|
||||
environment bypasses (Authentication:Mode=Disabled, anonymous localhost) that satisfy the
|
||||
ViewerPolicy MapRazorComponents<App>() applies. This page reads the gateway-central alarm
|
||||
monitor in process, exactly the surface AlarmsHub serves to external clients, so it carries the
|
||||
SAME policy as the hub — gating only the hub would leave the disclosure one navigation away.
|
||||
Alarm metadata stays galaxy-wide for an authenticated Viewer: the monitor has no per-session
|
||||
provenance, so the SEC-25 tag ACL has nothing to intersect. *@
|
||||
@attribute [Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)]
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
@inject IGatewayAlarmService AlarmService
|
||||
|
||||
@@ -33,6 +33,18 @@ public static class DashboardAuthenticationDefaults
|
||||
/// </summary>
|
||||
public const string HubClientsPolicy = "MxGateway.Dashboard.HubClients";
|
||||
|
||||
/// <summary>
|
||||
/// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the
|
||||
/// <c>/alarms</c> page that reads the same central monitor in process (SEC-40). Accepts the
|
||||
/// same two schemes as <see cref="HubClientsPolicy"/> but carries
|
||||
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
|
||||
/// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses
|
||||
/// (<c>Authentication:Mode=Disabled</c> and anonymous localhost) cannot satisfy it. The alarm
|
||||
/// feed is galaxy-wide and session-less, so unlike a session event stream there is no tag
|
||||
/// intersection to narrow it with — requiring a real role claim is the available narrowing.
|
||||
/// </summary>
|
||||
public const string AlarmsPolicy = "MxGateway.Dashboard.Alarms";
|
||||
|
||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
|
||||
@@ -40,9 +52,13 @@ public static class DashboardAuthenticationDefaults
|
||||
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
|
||||
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
|
||||
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
|
||||
/// <c>MxGateway:Dashboard:GroupToTag</c>; read by <see cref="IDashboardSessionAcl"/>. A
|
||||
/// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed
|
||||
/// default. Visibility only — it never grants data access.
|
||||
/// <c>MxGateway:Dashboard:GroupToTag</c>. Since SEC-41 <see cref="IDashboardSessionAcl"/> reads
|
||||
/// this claim only as a FALLBACK, for a principal that carries no
|
||||
/// <see cref="LdapGroupClaimType"/> claims to re-resolve from (the hub-token principal, whose
|
||||
/// payload carries tags but not groups); a cookie principal is re-resolved against current
|
||||
/// options instead, so a stamped tag cannot outlive a config-revoked grant. A principal
|
||||
/// carrying neither kind of claim is an empty-grant Viewer, which is the fail-closed default.
|
||||
/// Visibility only — it never grants data access.
|
||||
/// </summary>
|
||||
public const string DashboardTagClaimType = "zb:dashboardtag";
|
||||
|
||||
|
||||
@@ -104,9 +104,11 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardAccessDenied");
|
||||
|
||||
// SignalR hubs. Authorization is enforced on the hub class via
|
||||
// [Authorize(Policy = HubClientsPolicy)] so the policy accepts either
|
||||
// the dashboard cookie or a HubToken bearer.
|
||||
// SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both
|
||||
// policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and
|
||||
// events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a
|
||||
// real authenticated dashboard role (SEC-40) — see AlarmsHub for why the alarm feed is
|
||||
// gated harder than the session-scoped ones.
|
||||
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
|
||||
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
||||
endpoints.MapHub<EventsHub>("/hubs/events");
|
||||
|
||||
@@ -177,8 +177,20 @@ public static class DashboardServiceCollectionExtensions
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
||||
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
|
||||
// SEC-40. Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
|
||||
// and an external hub reader), but a requirement type DashboardAuthorizationHandler
|
||||
// does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost
|
||||
// can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role.
|
||||
authorization.AddPolicy(
|
||||
DashboardAuthenticationDefaults.AlarmsPolicy,
|
||||
policy => policy
|
||||
.AddAuthenticationSchemes(
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
||||
.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole));
|
||||
});
|
||||
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||
services.AddSingleton<IAuthorizationHandler, AuthenticatedDashboardRoleHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -26,22 +26,53 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// (ordinal-ignore-case).</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
|
||||
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
|
||||
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
|
||||
/// is an empty-grant Viewer.
|
||||
/// <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 the claim scan plus a session-registry lookup, with no
|
||||
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
|
||||
/// 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>.</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
|
||||
@@ -64,14 +95,53 @@ public sealed class DashboardSessionAcl(
|
||||
return false;
|
||||
}
|
||||
|
||||
DashboardOptions dashboard = options.Value.Dashboard;
|
||||
|
||||
if (session.Tags.Count == 0)
|
||||
{
|
||||
return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
|
||||
return dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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))
|
||||
|
||||
@@ -9,7 +9,18 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// <see cref="AllAlarmsGroup"/> on connect and receive every
|
||||
/// <c>AlarmFeedMessage</c> the monitor emits.
|
||||
/// </summary>
|
||||
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
||||
/// <remarks>
|
||||
/// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no
|
||||
/// session provenance, so the SEC-25 per-session tag ACL that scopes <c>EventsHub</c> has nothing
|
||||
/// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole
|
||||
/// subscription expression's alarm metadata. What SEC-40 tightened instead is the entry gate:
|
||||
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the
|
||||
/// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the
|
||||
/// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no
|
||||
/// longer receive the feed. The <c>/alarms</c> page carries the same policy — the two readers of
|
||||
/// one monitor must not disagree about who may see it.
|
||||
/// </remarks>
|
||||
[Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)]
|
||||
public sealed class AlarmsHub : Hub
|
||||
{
|
||||
public const string AllAlarmsGroup = "__alarms__";
|
||||
|
||||
@@ -22,6 +22,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// The source message is never mutated: it fans out from one feed to the gRPC
|
||||
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
|
||||
/// those audiences is subject to this dashboard-display flag.
|
||||
/// <para>
|
||||
/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub
|
||||
/// admission — <see cref="AlarmsHub"/> carries
|
||||
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
|
||||
/// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — and the alarm
|
||||
/// monitor is gateway-central with no per-session provenance, so there is no per-connection
|
||||
/// narrowing this loop could apply even in principle. Adding a filter here would imply a
|
||||
/// per-caller scope the feed does not carry.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="alarmService">The gateway's central alarm feed.</param>
|
||||
/// <param name="hubContext">Hub context used to broadcast to the alarms group.</param>
|
||||
|
||||
@@ -150,6 +150,12 @@ public static class GatewayApplication
|
||||
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
||||
// Register the gateway's browse-scope provider before AddZbGalaxyRepository so the
|
||||
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
|
||||
// The provider depends on GatewayGrpcAuthorizationInterceptor (registered globally above)
|
||||
// having pushed the ambient identity before the library's galaxy service runs. That
|
||||
// ordering is still required, but it is no longer trusted silently: with
|
||||
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
|
||||
// invariant when the identity is absent, instead of resolving to the full hierarchy
|
||||
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||
Security.Authorization.GatewayBrowseScopeProvider>();
|
||||
|
||||
|
||||
+53
-7
@@ -1,26 +1,72 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
|
||||
public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor)
|
||||
/// <remarks>
|
||||
/// Distinguishes two states the pre-GWC-38 code collapsed into one: "authenticated, no
|
||||
/// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity
|
||||
/// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the
|
||||
/// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly.
|
||||
/// </remarks>
|
||||
/// <param name="identityAccessor">Accessor for the ambient per-call API-key identity.</param>
|
||||
/// <param name="options">Gateway options supplying <c>MxGateway:Authentication:Mode</c>.</param>
|
||||
/// <param name="logger">Logger used to name the broken invariant when identity is absent.</param>
|
||||
public sealed class GatewayBrowseScopeProvider(
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<GatewayBrowseScopeProvider> logger)
|
||||
: IGalaxyBrowseScopeProvider
|
||||
{
|
||||
/// <summary>Resolves the Galaxy browse subtrees the calling API key is scoped to, from the ambient request identity.</summary>
|
||||
/// <param name="context">The gRPC server call context for the in-flight browse request.</param>
|
||||
/// <returns>The caller's configured browse subtrees, or <see langword="null"/>/empty when the caller is unscoped (full hierarchy).</returns>
|
||||
/// <exception cref="RpcException">
|
||||
/// <see cref="StatusCode.PermissionDenied"/> when API-key authentication is enabled but no
|
||||
/// ambient identity is present — the invariant described below has been broken and the
|
||||
/// request's scope cannot be established.
|
||||
/// </exception>
|
||||
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
|
||||
{
|
||||
// Invariant: the caller identity is the ambient one pushed by
|
||||
// GatewayGrpcAuthorizationInterceptor earlier in this same call. That interceptor is
|
||||
// registered globally and therefore runs before the library's galaxy service, so
|
||||
// Current is populated for any authenticated galaxy RPC. If it is somehow absent we
|
||||
// return empty BrowseSubtrees, which the library treats as "no scoping" (full
|
||||
// hierarchy) — safe only because the global interceptor has already authenticated and
|
||||
// authorized the request first. Do not reorder the interceptor without revisiting this.
|
||||
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
return constraints.BrowseSubtrees;
|
||||
// Current is populated for any authenticated galaxy RPC. Do not reorder the interceptor
|
||||
// without revisiting this.
|
||||
ApiKeyIdentity? identity = identityAccessor.Current;
|
||||
if (identity is not null)
|
||||
{
|
||||
return identity.EffectiveConstraints.BrowseSubtrees;
|
||||
}
|
||||
|
||||
// Mode=Disabled is the one configuration in which identity-absent is expected rather than
|
||||
// broken: nothing authenticates, so no key and no constraints exist to scope by, and the
|
||||
// deployment has already accepted an unauthenticated gRPC surface. Empty BrowseSubtrees is
|
||||
// what the library reads as "no scoping".
|
||||
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
|
||||
{
|
||||
return ApiKeyConstraints.Empty.BrowseSubtrees;
|
||||
}
|
||||
|
||||
// Authentication is on, so an identity MUST have been pushed before the galaxy service ran.
|
||||
// Failing open here would hand a browse-scoped key the full hierarchy with nothing logged
|
||||
// and nothing failing — a constraint-narrowing control's absence is invisible in normal
|
||||
// operation. Deny instead, and name the invariant so the misconfiguration is diagnosable
|
||||
// from one log line rather than from a scoping audit nobody thought to run.
|
||||
logger.LogWarning(
|
||||
"Galaxy browse scope could not be resolved for {Method}: no ambient API-key identity "
|
||||
+ "while Authentication:Mode is {Mode}. GatewayGrpcAuthorizationInterceptor must run "
|
||||
+ "before the Galaxy service on every call; the request is denied rather than served "
|
||||
+ "unscoped.",
|
||||
context.Method,
|
||||
options.Value.Authentication.Mode);
|
||||
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.PermissionDenied,
|
||||
"Browse scope could not be established for this request."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
@@ -13,7 +14,9 @@ using ZB.MOM.WW.Secrets.Ui;
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Renders <see cref="MainLayout"/> and asserts whether the side rail emits the Secrets link.
|
||||
/// Renders <see cref="MainLayout"/> and asserts which policy-gated links the side rail emits —
|
||||
/// Secrets (<c>secrets:manage</c>) and, since SEC-40, Alarms
|
||||
/// (<see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -39,6 +42,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
||||
public sealed class SecretsNavRenderTests
|
||||
{
|
||||
private const string SecretsHref = "/admin/secrets";
|
||||
private const string AlarmsHref = "/alarms";
|
||||
|
||||
/// <summary>
|
||||
/// The gate's proof. A Viewer holds a real, authenticated identity and still must not be offered
|
||||
@@ -95,11 +99,48 @@ public sealed class SecretsNavRenderTests
|
||||
Assert.Contains("/apikeys", html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-40's nav half: an anonymous caller — the read-only localhost path — is not offered the
|
||||
/// Alarms link, because the page and hub now refuse them. Same reasoning as the Secrets
|
||||
/// negative above: this is the assertion that distinguishes a gate from an inert one.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Rail_OmitsAlarmsLink_ForAnonymous()
|
||||
{
|
||||
string html = await RenderRailAsync();
|
||||
|
||||
Assert.DoesNotContain(AlarmsHref, html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control for that negative: an authenticated Viewer still gets the Alarms link. SEC-40
|
||||
/// excludes the environment bypasses, not Viewers — alarm metadata stays galaxy-wide for a
|
||||
/// real dashboard role because the central monitor carries no per-session provenance.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Rail_EmitsAlarmsLink_ForViewer()
|
||||
{
|
||||
string html = await RenderRailAsync(DashboardRoles.Viewer);
|
||||
|
||||
Assert.Contains(AlarmsHref, html, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task<string> RenderRailAsync(params string[] roles)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddAuthorization(options => options.AddSecretsAuthorization());
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddSecretsAuthorization();
|
||||
// The rail's Alarms AuthorizeView resolves this by name; an unregistered policy throws
|
||||
// rather than denying, so it must be present for any rail render (SEC-40).
|
||||
options.AddPolicy(
|
||||
DashboardAuthenticationDefaults.AlarmsPolicy,
|
||||
policy => policy.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole));
|
||||
});
|
||||
services.AddSingleton<IAuthorizationHandler, AuthenticatedDashboardRoleHandler>();
|
||||
services.AddCascadingAuthenticationState();
|
||||
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider(roles));
|
||||
services.AddSingleton<NavigationManager, StubNavigationManager>();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
using AuthenticationOptions = ZB.MOM.WW.MxGateway.Server.Configuration.AuthenticationOptions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Covers SEC-40: the alarm feed requires a real authenticated dashboard role, so the read-only
|
||||
/// environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no longer
|
||||
/// receive galaxy-wide alarm metadata.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The enforcement is a requirement TYPE distinction — <see cref="DashboardAuthorizationHandler"/>
|
||||
/// is an <c>AuthorizationHandler<DashboardAuthorizationRequirement></c> and therefore never
|
||||
/// runs for <see cref="AuthenticatedDashboardRoleRequirement"/> — so the tests below assert both
|
||||
/// halves: that both handlers together still deny a bypassed caller, and that the policy the two
|
||||
/// alarm surfaces actually carry is built from that requirement rather than the bypassable one.
|
||||
/// Asserting only the handler would pass even if the hub were left on the old policy.
|
||||
/// </remarks>
|
||||
public sealed class AlarmFeedAuthorizationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The bypass that satisfies <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>
|
||||
/// — auth disabled AND a loopback request with <c>AllowAnonymousLocalhost</c> on, i.e. both
|
||||
/// arms at once — does not satisfy the alarm requirement, with the real bypass handler present
|
||||
/// in the same evaluation.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmRequirement_AnonymousLocalhostAndAuthDisabled_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await EvaluateAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
AuthenticatedDashboardRoleRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Control: the SAME evaluation environment satisfies the bypassable requirement. Without this
|
||||
/// pairing the test above could pass because the bypass was misconfigured rather than because
|
||||
/// the requirement type excludes it.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ViewerRequirement_AnonymousLocalhostAndAuthDisabled_StillSucceeds()
|
||||
{
|
||||
AuthorizationHandlerContext context = await EvaluateAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An authenticated Viewer — the ordinary LDAP-logged-in dashboard user — does satisfy the
|
||||
/// alarm requirement: SEC-40 excludes the environment bypasses, not Viewers. Alarm metadata
|
||||
/// stays galaxy-wide for them because the monitor is gateway-central and carries no
|
||||
/// per-session provenance to tag-scope by.
|
||||
/// </summary>
|
||||
/// <param name="role">The dashboard role the authenticated caller holds.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(DashboardRoles.Viewer)]
|
||||
[InlineData(DashboardRoles.Admin)]
|
||||
public async Task AlarmRequirement_AuthenticatedDashboardRole_Succeeds(string role)
|
||||
{
|
||||
AuthorizationHandlerContext context = await EvaluateAsync(
|
||||
CreatePrincipal(role),
|
||||
AuthenticatedDashboardRoleRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A principal carrying a dashboard role claim but no authentication type does not satisfy the
|
||||
/// requirement: "real role claim" means an authenticated identity, matching how the session
|
||||
/// ACL and <c>DashboardSessionAdminService</c> read a role.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmRequirement_UnauthenticatedRoleClaim_DoesNotSucceed()
|
||||
{
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
||||
authenticationType: null,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role));
|
||||
|
||||
AuthorizationHandlerContext context = await EvaluateAsync(
|
||||
principal,
|
||||
AuthenticatedDashboardRoleRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The registered policy is built from the non-bypassable requirement and from neither
|
||||
/// <see cref="DashboardAuthorizationRequirement"/> nor a role list the bypass handler could
|
||||
/// reach, and it still accepts both hub-capable schemes so an external hub client can
|
||||
/// authenticate with a bearer token.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmsPolicy_IsRegisteredWithNonBypassableRequirement()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IAuthorizationPolicyProvider provider = app.Services
|
||||
.GetRequiredService<IAuthorizationPolicyProvider>();
|
||||
|
||||
AuthorizationPolicy? policy = await provider
|
||||
.GetPolicyAsync(DashboardAuthenticationDefaults.AlarmsPolicy);
|
||||
|
||||
Assert.NotNull(policy);
|
||||
Assert.Contains(policy.Requirements, r => r is AuthenticatedDashboardRoleRequirement);
|
||||
Assert.DoesNotContain(policy.Requirements, r => r is DashboardAuthorizationRequirement);
|
||||
Assert.Equal(
|
||||
[
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
||||
],
|
||||
policy.AuthenticationSchemes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Both readers of the one central monitor carry the alarm policy: the hub (external clients)
|
||||
/// and the <c>/alarms</c> Razor endpoint (the in-page reader). Gating only the hub would leave
|
||||
/// the same metadata one navigation away for a bypassed caller.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AlarmsHubAndAlarmsPage_BothCarryTheAlarmsPolicy()
|
||||
{
|
||||
AuthorizeAttribute? hubAttribute = typeof(AlarmsHub)
|
||||
.GetCustomAttribute<AuthorizeAttribute>(inherit: false);
|
||||
|
||||
Assert.Equal(DashboardAuthenticationDefaults.AlarmsPolicy, hubAttribute?.Policy);
|
||||
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
RouteEndpoint alarmsPage = ((IEndpointRouteBuilder)app).DataSources
|
||||
.SelectMany(source => source.Endpoints)
|
||||
.OfType<RouteEndpoint>()
|
||||
.Single(endpoint => endpoint.RoutePattern.RawText == "/alarms");
|
||||
|
||||
Assert.Contains(
|
||||
alarmsPage.Metadata.OfType<IAuthorizeData>(),
|
||||
data => data.Policy == DashboardAuthenticationDefaults.AlarmsPolicy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs BOTH dashboard authorization handlers over one requirement in the most permissive
|
||||
/// environment the gateway supports (auth disabled, loopback, anonymous localhost allowed),
|
||||
/// mirroring how the authorization service invokes every registered handler.
|
||||
/// </summary>
|
||||
/// <param name="principal">The caller.</param>
|
||||
/// <param name="requirement">The single requirement under evaluation.</param>
|
||||
/// <returns>The context after both handlers have run.</returns>
|
||||
private static async Task<AuthorizationHandlerContext> EvaluateAsync(
|
||||
ClaimsPrincipal principal,
|
||||
IAuthorizationRequirement requirement)
|
||||
{
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = IPAddress.Loopback;
|
||||
|
||||
IOptions<GatewayOptions> options = Options.Create(new GatewayOptions
|
||||
{
|
||||
Authentication = new AuthenticationOptions { Mode = AuthenticationMode.Disabled },
|
||||
Dashboard = new DashboardOptions { AllowAnonymousLocalhost = true },
|
||||
});
|
||||
|
||||
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
|
||||
|
||||
await new DashboardAuthorizationHandler(
|
||||
new HttpContextAccessor { HttpContext = httpContext },
|
||||
options).HandleAsync(context);
|
||||
await new AuthenticatedDashboardRoleHandler().HandleAsync(context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string role) =>
|
||||
new(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Role, role)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role));
|
||||
}
|
||||
@@ -178,12 +178,112 @@ public sealed class DashboardSessionAclTests
|
||||
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-41, the load-bearing regression: a cookie-style principal whose tag claim was stamped
|
||||
/// at login under an older <c>GroupToTag</c> 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-resolution is ordinal-ignore-case on both sides — the group key against the map (which
|
||||
/// <see cref="DashboardGroupTagMapping"/> owns) and the resolved tag against the session's tag
|
||||
/// set. Pinned because the pre-SEC-41 claim scan got the second comparison from
|
||||
/// <c>Session.Tags</c>'s own comparer, and the re-resolution arm probes the grant set in the
|
||||
/// opposite direction.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The documented fallback: a hub-token principal carries the tags <see cref="HubTokenService"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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)
|
||||
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly,
|
||||
Dictionary<string, string[]>? groupToTag = null)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
|
||||
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<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string[]>(groupToTag, StringComparer.OrdinalIgnoreCase),
|
||||
},
|
||||
};
|
||||
|
||||
return new DashboardSessionAcl(
|
||||
@@ -193,13 +293,26 @@ public sealed class DashboardSessionAclTests
|
||||
Options.Create(options));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
|
||||
/// <summary>
|
||||
/// Builds a dashboard principal. <paramref name="groups"/> 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.
|
||||
/// </summary>
|
||||
/// <param name="roles">Role claims to stamp.</param>
|
||||
/// <param name="tags">Login-stamped visibility tag claims, if any.</param>
|
||||
/// <param name="groups">LDAP group claims, if any.</param>
|
||||
/// <returns>An authenticated principal carrying the requested claims.</returns>
|
||||
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null, string[]? groups = null)
|
||||
{
|
||||
List<Claim> 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,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
@@ -23,8 +26,11 @@ public sealed class GalaxyRepositoryHostWiringTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies the full production authz-filtering chain end-to-end.
|
||||
/// Phase 1: no identity pushed → <c>BrowseChildren</c> returns both children
|
||||
/// (unconstrained). Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
|
||||
/// Phase 1: an identity carrying no <c>BrowseSubtrees</c> pushed → <c>BrowseChildren</c>
|
||||
/// returns both children (unconstrained). That, not an absent identity, is production's
|
||||
/// unscoped case since GWC-38 — with <c>Authentication:Mode=ApiKey</c> an absent identity
|
||||
/// now denies, which the isolated provider test pins.
|
||||
/// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
|
||||
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
|
||||
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
|
||||
/// service → <c>BrowseChildren</c> returns empty children.
|
||||
@@ -35,7 +41,16 @@ public sealed class GalaxyRepositoryHostWiringTests
|
||||
{
|
||||
// Arrange: construct the REAL host seam — not stubs.
|
||||
GatewayRequestIdentityAccessor identityAccessor = new();
|
||||
GatewayBrowseScopeProvider scopeProvider = new(identityAccessor);
|
||||
GatewayBrowseScopeProvider scopeProvider = new(
|
||||
identityAccessor,
|
||||
Options.Create(new GatewayOptions
|
||||
{
|
||||
Authentication = new Server.Configuration.AuthenticationOptions
|
||||
{
|
||||
Mode = AuthenticationMode.ApiKey,
|
||||
},
|
||||
}),
|
||||
NullLogger<GatewayBrowseScopeProvider>.Instance);
|
||||
|
||||
GalaxyObject[] objects = CreateFilterObjects();
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
@@ -55,13 +70,23 @@ public sealed class GalaxyRepositoryHostWiringTests
|
||||
new GalaxyDeployNotifier(),
|
||||
scopeProvider);
|
||||
|
||||
// Phase 1: no identity on the accessor → browse is unconstrained.
|
||||
// Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
|
||||
BrowseChildrenReply unconstrained = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||
new TestServerCallContext());
|
||||
// Phase 1: an authenticated identity with no BrowseSubtrees constraint → browse is
|
||||
// unconstrained. Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
|
||||
ApiKeyIdentity unscopedIdentity = new(
|
||||
KeyId: "test-key",
|
||||
KeyPrefix: "mxgw_test",
|
||||
DisplayName: "unscoped",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
|
||||
Constraints: ApiKeyConstraints.Empty);
|
||||
|
||||
Assert.Equal(2, unconstrained.Children.Count);
|
||||
using (identityAccessor.Push(unscopedIdentity))
|
||||
{
|
||||
BrowseChildrenReply unconstrained = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(2, unconstrained.Children.Count);
|
||||
}
|
||||
|
||||
// Phase 2: push a constraint whose BrowseSubtrees do not match any seeded object.
|
||||
// GatewayBrowseScopeProvider.ResolveBrowseSubtrees must return ["NonExistent"],
|
||||
|
||||
+61
-6
@@ -1,3 +1,7 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
@@ -19,7 +23,7 @@ public sealed class GatewayBrowseScopeProviderTests
|
||||
public void ResolveBrowseSubtrees_WithCurrentIdentity_ReturnsConstraintBrowseSubtrees()
|
||||
{
|
||||
StubIdentityAccessor accessor = new(CreateIdentity(["AreaA"]));
|
||||
GatewayBrowseScopeProvider provider = new(accessor);
|
||||
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
|
||||
|
||||
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||
|
||||
@@ -27,14 +31,16 @@ public sealed class GatewayBrowseScopeProviderTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no ambient identity, the provider falls back to the empty browse-subtree
|
||||
/// list, which the library treats as "no scoping" rather than denying everything.
|
||||
/// An authenticated key with no BrowseSubtrees constraint is legitimately unscoped: the
|
||||
/// empty list is what the library reads as "no scoping". This is the state GWC-38 required
|
||||
/// to stay distinguishable from an absent identity, so it is asserted alongside its
|
||||
/// denying twin below rather than left implicit.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveBrowseSubtrees_WithNoCurrentIdentity_ReturnsEmptyList()
|
||||
public void ResolveBrowseSubtrees_WithUnconstrainedIdentity_ReturnsEmptyList()
|
||||
{
|
||||
StubIdentityAccessor accessor = new(current: null);
|
||||
GatewayBrowseScopeProvider provider = new(accessor);
|
||||
StubIdentityAccessor accessor = new(CreateIdentity([]));
|
||||
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
|
||||
|
||||
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||
|
||||
@@ -42,6 +48,55 @@ public sealed class GatewayBrowseScopeProviderTests
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GWC-38: with API-key authentication enabled, an absent ambient identity means the
|
||||
/// interceptor-ordering invariant is broken — the request's browse scope cannot be
|
||||
/// established. The provider must DENY rather than resolve to the full hierarchy, because
|
||||
/// a silently-unscoped browse is an invisible authorization bypass. The status code is
|
||||
/// pinned: PermissionDenied is what the gRPC surface returns for an authorization failure
|
||||
/// the caller cannot fix by re-authenticating.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndApiKeyAuth_ThrowsPermissionDenied()
|
||||
{
|
||||
StubIdentityAccessor accessor = new(current: null);
|
||||
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
|
||||
|
||||
RpcException exception = Assert.Throws<RpcException>(
|
||||
() => provider.ResolveBrowseSubtrees(new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Under <c>Authentication:Mode=Disabled</c> nothing authenticates, so an absent identity
|
||||
/// is the expected steady state rather than a broken invariant: there is no key and no
|
||||
/// constraint to scope by. The provider keeps returning unscoped, so disabling
|
||||
/// authentication does not turn every Galaxy browse into a PermissionDenied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndAuthDisabled_ReturnsEmptyList()
|
||||
{
|
||||
StubIdentityAccessor accessor = new(current: null);
|
||||
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.Disabled);
|
||||
|
||||
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
private static GatewayBrowseScopeProvider CreateProvider(
|
||||
StubIdentityAccessor accessor,
|
||||
AuthenticationMode mode) =>
|
||||
new(
|
||||
accessor,
|
||||
Options.Create(new GatewayOptions
|
||||
{
|
||||
Authentication = new Server.Configuration.AuthenticationOptions { Mode = mode },
|
||||
}),
|
||||
NullLogger<GatewayBrowseScopeProvider>.Instance);
|
||||
|
||||
private static ApiKeyIdentity CreateIdentity(IReadOnlyList<string> browseSubtrees) =>
|
||||
new(
|
||||
KeyId: "operator01",
|
||||
|
||||
Reference in New Issue
Block a user