using System.Runtime.CompilerServices; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MxGateway.Contracts; using MxGateway.Contracts.Proto; using MxGateway.Server.Configuration; using MxGateway.Server.Grpc; using MxGateway.Server.Metrics; using MxGateway.Server.Security.Authentication; using MxGateway.Server.Security.Authorization; using MxGateway.Server.Sessions; namespace MxGateway.Tests.Security.Authorization; public sealed class GatewayGrpcAuthorizationInterceptorTests { /// Verifies that missing API key returns unauthenticated status. [Fact] public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail( ApiKeyVerificationFailure.MissingOrMalformedCredentials)), 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. [Fact] public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.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. [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. [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. [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 TestServerStreamWriter(), 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. [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream() { GatewayRequestIdentityAccessor identityAccessor = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), identityAccessor); TestServerStreamWriter 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. [Fact] public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification() { GatewayRequestIdentityAccessor identityAccessor = new(); FakeApiKeyVerifier verifier = new(ApiKeyVerificationResult.Fail( ApiKeyVerificationFailure.MissingOrMalformedCredentials)); 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. /// [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. /// [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. /// [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); } 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); } 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 ApiKeyVerificationResult SuccessWithScopes(params string[] scopes) { return ApiKeyVerificationResult.Success(new ApiKeyIdentity( KeyId: "operator01", KeyPrefix: "mxgw_operator01", DisplayName: "Operator Key", Scopes: new HashSet(scopes, StringComparer.Ordinal))); } 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, 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 Task CloseSessionAsync( string sessionId, 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, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } } /// Constraint enforcer that permits every operation for composition tests. private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer { /// public Task CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, CancellationToken cancellationToken) => Task.FromResult(null); /// public Task CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, CancellationToken cancellationToken) => Task.FromResult(null); /// public Task CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, CancellationToken cancellationToken) => Task.FromResult(null); /// public Task RecordDenialAsync( ApiKeyIdentity? identity, string commandKind, string target, ConstraintFailure failure, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult 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 cancellationToken) { WasCalled = true; LastAuthorizationHeader = authorizationHeader; return Task.FromResult(result); } } private sealed class TestServerStreamWriter : IServerStreamWriter { /// Gets messages written to the stream. public List Messages { get; } = []; /// Gets or sets write options for the stream. public WriteOptions? WriteOptions { get; set; } /// Writes a message to the stream. /// The message to write. /// Task representing the write operation. public Task WriteAsync(T message) { Messages.Add(message); return Task.CompletedTask; } } private sealed class TestServerCallContext( Metadata requestHeaders, CancellationToken cancellationToken = default) : ServerCallContext { private readonly Metadata responseTrailers = []; private readonly Dictionary userState = []; private Status status; private WriteOptions? writeOptions; /// protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; /// protected override string HostCore => "localhost"; /// protected override string PeerCore => "ipv4:127.0.0.1:5000"; /// protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); /// protected override Metadata RequestHeadersCore => requestHeaders; /// protected override CancellationToken CancellationTokenCore => cancellationToken; /// protected override Metadata ResponseTrailersCore => responseTrailers; /// protected override Status StatusCore { get => status; set => status = value; } /// protected override WriteOptions? WriteOptionsCore { get => writeOptions; set => writeOptions = value; } /// protected override AuthContext AuthContextCore { get; } = new( string.Empty, new Dictionary>(StringComparer.Ordinal)); /// protected override IDictionary UserStateCore => userState; /// protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) { return Task.CompletedTask; } /// protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) { throw new NotSupportedException(); } } }