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:
Joseph Doherty
2026-08-20 09:31:21 -04:00
21 changed files with 855 additions and 68 deletions
@@ -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&lt;DashboardAuthorizationRequirement&gt;</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>();
@@ -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&lt;DashboardAuthorizationRequirement&gt;</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"],
@@ -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",