using System.Runtime.CompilerServices; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Grpc; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Tests.TestSupport; // The handler exposes the gateway's constraint-bearing identity; alias the shared library identity // (returned by the verifier) so the two can be referenced unambiguously. using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity; using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; public sealed class GatewayGrpcAuthorizationInterceptorTests { /// Verifies that missing API key returns unauthenticated status. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(Failure(ApiKeyFailure.MissingOrMalformed)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), new TestServerCallContext([]), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); Assert.DoesNotContain("secret", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); } /// Verifies that invalid API key error does not expose raw credentials. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_super-secret"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); Assert.DoesNotContain("super-secret", exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that valid key without required scope returns permission denied. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that valid key with scope sets request identity for the handler. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity() { GatewayRequestIdentityAccessor identityAccessor = new(); ApiKeyIdentity? identitySeenByHandler = null; GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), identityAccessor); OpenSessionReply reply = await interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => { identitySeenByHandler = identityAccessor.Current; return Task.FromResult(new OpenSessionReply { SessionId = "session-1" }); }); Assert.Equal("session-1", reply.SessionId); Assert.NotNull(identitySeenByHandler); Assert.Equal("operator01", identitySeenByHandler.KeyId); Assert.Null(identityAccessor.Current); } /// Verifies that server stream handler requires proper scope. /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.ServerStreamingServerHandler( new StreamEventsRequest(), new RecordingServerStreamWriter(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _, _) => Task.CompletedTask)); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that server stream handler allows streams with proper scope. /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream() { GatewayRequestIdentityAccessor identityAccessor = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), identityAccessor); RecordingServerStreamWriter streamWriter = new(); await interceptor.ServerStreamingServerHandler( new StreamEventsRequest(), streamWriter, ContextWithAuthorization("Bearer mxgw_operator01_secret"), async (_, writer, _) => { Assert.Equal("operator01", identityAccessor.Current?.KeyId); await writer.WriteAsync(new MxEvent { SessionId = "session-1" }); }); MxEvent eventMessage = Assert.Single(streamWriter.Messages); Assert.Equal("session-1", eventMessage.SessionId); Assert.Null(identityAccessor.Current); } /// Verifies that disabled authentication skips API key verification. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification() { GatewayRequestIdentityAccessor identityAccessor = new(); FakeApiKeyVerifier verifier = new(Failure(ApiKeyFailure.MissingOrMalformed)); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( verifier, identityAccessor, AuthenticationMode.Disabled); OpenSessionReply reply = await interceptor.UnaryServerHandler( new OpenSessionRequest(), new TestServerCallContext([]), (_, _) => Task.FromResult(new OpenSessionReply { SessionId = "session-1" })); Assert.Equal("session-1", reply.SessionId); Assert.False(verifier.WasCalled); Assert.Null(identityAccessor.Current); } /// /// End-to-end composition test: runs an OpenSession call through the real /// interceptor in front of the real with a key /// that lacks the session:open scope, and asserts the interceptor denies the /// call with before the service runs. /// /// A task that represents the asynchronous operation. [Fact] public async Task InterceptorComposedWithService_OpenSessionMissingScope_DeniesBeforeServiceRuns() { GatewayRequestIdentityAccessor identityAccessor = new(); RecordingSessionManager sessionManager = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), identityAccessor); MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest { ClientSessionName = "operator-session" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (request, context) => service.OpenSession(request, context))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); Assert.Equal(0, sessionManager.OpenSessionCount); } /// /// End-to-end composition test: runs an OpenSession call through the real /// interceptor in front of the real with a key /// that holds session:open, and asserts the service runs and observes the /// interceptor-supplied identity. /// /// A task that represents the asynchronous operation. [Fact] public async Task InterceptorComposedWithService_OpenSessionWithScope_RunsServiceWithIdentity() { GatewayRequestIdentityAccessor identityAccessor = new(); RecordingSessionManager sessionManager = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), identityAccessor); MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); OpenSessionReply reply = await interceptor.UnaryServerHandler( new OpenSessionRequest { ClientSessionName = "operator-session" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (request, context) => service.OpenSession(request, context)); Assert.Equal("session-1", reply.SessionId); Assert.Equal(1, sessionManager.OpenSessionCount); Assert.Equal("Operator Key", sessionManager.LastClientIdentity); } /// /// End-to-end composition test: an Invoke call through the real interceptor in /// front of the real service with a key holding only invoke:read is denied /// because the wrapped command is a write, confirming command-scope mapping is /// enforced through the full composition. /// /// A task that represents the asynchronous operation. [Fact] public async Task InterceptorComposedWithService_InvokeWriteCommandWithReadScope_DeniesBeforeServiceRuns() { GatewayRequestIdentityAccessor identityAccessor = new(); RecordingSessionManager sessionManager = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), identityAccessor); MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); MxCommandRequest request = new() { SessionId = "session-1", Command = new MxCommand { Kind = MxCommandKind.Write, Write = new WriteCommand { ServerHandle = 1, ItemHandle = 2 }, }, }; RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( request, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (req, context) => service.Invoke(req, context))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal); Assert.Equal(0, sessionManager.InvokeCount); } /// /// Verifies the interceptor denies AcknowledgeAlarm calls that lack /// . Ack is a write-shaped mutation against /// alarm state, so it carries the same scope as MxCommandKind.Write. /// /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => Task.FromResult(new AcknowledgeAlarmReply()))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that an API key holding invoke:write may call AcknowledgeAlarm. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeWrite)), new GatewayRequestIdentityAccessor()); bool handlerRan = false; AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler( new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => { handlerRan = true; return Task.FromResult(new AcknowledgeAlarmReply()); }); Assert.NotNull(reply); Assert.True(handlerRan); } /// /// Verifies the interceptor denies QueryActiveAlarms server-streaming calls that /// lack . Active-alarm snapshots are part of the /// alarm/event surface and share the same scope as StreamEvents. /// /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.ServerStreamingServerHandler( new StreamAlarmsRequest(), new RecordingServerStreamWriter(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _, _) => Task.CompletedTask)); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that an API key holding events:read may call QueryActiveAlarms. /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), new GatewayRequestIdentityAccessor()); RecordingServerStreamWriter streamWriter = new(); await interceptor.ServerStreamingServerHandler( new StreamAlarmsRequest(), streamWriter, ContextWithAuthorization("Bearer mxgw_operator01_secret"), async (_, writer, _) => { await writer.WriteAsync(new ActiveAlarmSnapshot()); }); Assert.Single(streamWriter.Messages); } private static MxAccessGatewayService CreateService( ISessionManager sessionManager, IGatewayRequestIdentityAccessor identityAccessor) { return new MxAccessGatewayService( sessionManager, identityAccessor, new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), new MxAccessGrpcMapper(), new NoOpEventStreamService(), new GatewayMetrics(), NullLogger.Instance, new FakeGatewayAlarmService()); } private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( IApiKeyVerifier apiKeyVerifier, IGatewayRequestIdentityAccessor identityAccessor, AuthenticationMode authenticationMode = AuthenticationMode.ApiKey) { return new GatewayGrpcAuthorizationInterceptor( apiKeyVerifier, new GatewayGrpcScopeResolver(), identityAccessor, Options.Create(new GatewayOptions { Authentication = new AuthenticationOptions { Mode = authenticationMode } })); } private static ApiKeyVerification SuccessWithScopes(params string[] scopes) { return new ApiKeyVerification( Succeeded: true, Identity: new LibApiKeyIdentity( KeyId: "operator01", DisplayName: "Operator Key", Scopes: new HashSet(scopes, StringComparer.Ordinal), Constraints: null), Failure: null); } private static ApiKeyVerification Failure(ApiKeyFailure failure) { return new ApiKeyVerification(Succeeded: false, Identity: null, Failure: failure); } private static TestServerCallContext ContextWithAuthorization(string authorizationHeader) { return new TestServerCallContext([new Metadata.Entry("authorization", authorizationHeader)]); } /// Records whether the gateway service ran past the interceptor for composition tests. private sealed class RecordingSessionManager : ISessionManager { /// Gets the number of times OpenSessionAsync was invoked. public int OpenSessionCount { get; private set; } /// Gets the number of times InvokeAsync was invoked. public int InvokeCount { get; private set; } /// Gets the last client identity passed to OpenSessionAsync. public string? LastClientIdentity { get; private set; } /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, string? ownerKeyId, CancellationToken cancellationToken) { OpenSessionCount++; LastClientIdentity = clientIdentity; GatewaySession session = new( "session-1", GatewayContractInfo.DefaultBackendName, "pipe", "nonce", clientIdentity ?? "client", "client-session", "client-correlation", TimeSpan.FromSeconds(7), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10), DateTimeOffset.UtcNow); return Task.FromResult(session); } /// public bool TryGetSession(string sessionId, out GatewaySession session) { session = null!; return false; } /// public Task InvokeAsync( string sessionId, WorkerCommand command, CancellationToken cancellationToken) { InvokeCount++; return Task.FromResult(new WorkerCommandReply()); } /// public IAsyncEnumerable ReadEventsAsync( string sessionId, CancellationToken cancellationToken) { return AsyncEnumerable.Empty(); } /// public IAsyncEnumerable ReadAlarmEventsAsync( string sessionId, CancellationToken cancellationToken) { return AsyncEnumerable.Empty(); } /// public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) { return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } /// public Task KillWorkerAsync( string sessionId, string reason, CancellationToken cancellationToken) { return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) { return Task.FromResult(0); } /// public Task ShutdownAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } } /// Event stream service that yields nothing; alarm/event RPCs are not under test here. private sealed class NoOpEventStreamService : IEventStreamService { /// public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, string? callerKeyId, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } } private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier { /// Gets whether the verifier was called. public bool WasCalled { get; private set; } /// Gets the last authorization header seen by the verifier. public string? LastAuthorizationHeader { get; private set; } /// Verifies the authorization header against stored result. /// The authorization header to verify. /// Cancellation token. /// Configured verification result. public Task VerifyAsync( string authorizationHeader, CancellationToken ct) { WasCalled = true; LastAuthorizationHeader = authorizationHeader; return Task.FromResult(result); } } }