From fad0ac99488bb94c6de9e3f14b8211ef6a3d56de Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:01:34 -0400 Subject: [PATCH] Issue #8: add grpc authentication and scope authorization --- docs/gateway-process-design.md | 26 ++ gateway.md | 10 +- src/MxGateway.Server/GatewayApplication.cs | 2 + src/MxGateway.Server/MxGateway.Server.csproj | 1 + .../GatewayGrpcAuthorizationInterceptor.cs | 74 +++++ .../Authorization/GatewayGrpcScopeResolver.cs | 40 +++ .../GatewayRequestIdentityAccessor.cs | 38 +++ .../Security/Authorization/GatewayScopes.cs | 13 + ...uthorizationServiceCollectionExtensions.cs | 16 ++ .../IGatewayRequestIdentityAccessor.cs | 10 + ...atewayGrpcAuthorizationInterceptorTests.cs | 267 ++++++++++++++++++ .../GatewayGrpcScopeResolverTests.cs | 54 ++++ 12 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs create mode 100644 src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs create mode 100644 src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs create mode 100644 src/MxGateway.Server/Security/Authorization/GatewayScopes.cs create mode 100644 src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs create mode 100644 src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs create mode 100644 src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs create mode 100644 src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs diff --git a/docs/gateway-process-design.md b/docs/gateway-process-design.md index d2cf6ee..6d35349 100644 --- a/docs/gateway-process-design.md +++ b/docs/gateway-process-design.md @@ -612,6 +612,15 @@ hashes the presented secret, and compares the stored and presented hashes with results distinguish malformed credentials, missing keys, revoked keys, missing pepper configuration, and hash mismatch for internal authorization handling. +`GatewayGrpcAuthorizationInterceptor` enforces this authentication model for +public gRPC calls. Missing, malformed, revoked, unknown, or mismatched keys fail +with `Unauthenticated`. Authenticated calls missing the scope required by the +RPC fail with `PermissionDenied`. The interceptor applies to unary calls and +server-streaming calls and stores the authenticated `ApiKeyIdentity` in +`IGatewayRequestIdentityAccessor` for the duration of the request handler. +`Authentication:Mode` set to `Disabled` bypasses API-key verification for local +development only. + Recommended scopes: - `session:open` @@ -677,6 +686,20 @@ Commands requiring authorization: - worker shutdown diagnostics, - metadata queries if they expose sensitive plant structure. +Current gRPC scope mapping: + +- `OpenSession` requires `session:open`. +- `CloseSession` requires `session:close`. +- `StreamEvents` and `DrainEvents` require `events:read`. +- read-style MXAccess commands such as `Register`, `AddItem`, `Advise`, and + `Ping` require `invoke:read`. +- `Write` and `Write2` require `invoke:write`. +- `WriteSecured`, `WriteSecured2`, and `AuthenticateUser` require + `invoke:secure`. +- metadata commands such as `ArchestrAUserToId`, `GetSessionState`, and + `GetWorkerInfo` require `metadata:read`. +- `ShutdownWorker` requires `admin`. + ### Worker IPC Named pipes should be local only. Pipe ACLs should restrict access to: @@ -819,6 +842,9 @@ workers and fake transports. Focused tests: - session state transitions, +- gRPC API-key authentication for unary and streaming calls, +- gRPC scope mapping for sessions, invokes, events, metadata, and admin + commands, - worker startup failures, - protocol version mismatch, - malformed frame handling, diff --git a/gateway.md b/gateway.md index 5d378f2..3bc2c47 100644 --- a/gateway.md +++ b/gateway.md @@ -566,9 +566,13 @@ Because each client owns one worker, a crash or leak affects only that session. External gateway: - use TLS for remote gRPC if crossing machine boundaries, -- authenticate clients with Windows auth, mTLS, or a deployment-specific token, -- authorize access to commands that can write, authenticate users, or alter - runtime state. +- authenticate v1 gRPC clients with `authorization: Bearer + mxgw__` API-key metadata, +- reject missing or invalid API keys with gRPC `Unauthenticated`, +- reject valid keys that lack the required session, invoke, event, metadata, or + admin scope with gRPC `PermissionDenied`, +- authorize access to commands that can write, authenticate users, expose + metadata, stream events, or alter runtime state. Internal worker IPC: diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index 4167248..0215706 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -3,6 +3,7 @@ using MxGateway.Server.Configuration; using MxGateway.Server.Diagnostics; using MxGateway.Server.Metrics; using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; using MxGateway.Server.Workers; namespace MxGateway.Server; @@ -26,6 +27,7 @@ public static class GatewayApplication builder.Services.AddGatewayConfiguration(); builder.Services.AddSqliteAuthStore(); + builder.Services.AddGatewayGrpcAuthorization(); builder.Services.AddHealthChecks(); builder.Services.AddSingleton(); builder.Services.AddWorkerProcessLauncher(); diff --git a/src/MxGateway.Server/MxGateway.Server.csproj b/src/MxGateway.Server/MxGateway.Server.csproj index 9b0b2f0..f684a20 100644 --- a/src/MxGateway.Server/MxGateway.Server.csproj +++ b/src/MxGateway.Server/MxGateway.Server.csproj @@ -5,6 +5,7 @@ + diff --git a/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs b/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs new file mode 100644 index 0000000..b2beb7f --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs @@ -0,0 +1,74 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.Options; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Server.Security.Authorization; + +public sealed class GatewayGrpcAuthorizationInterceptor( + IApiKeyVerifier apiKeyVerifier, + GatewayGrpcScopeResolver scopeResolver, + IGatewayRequestIdentityAccessor identityAccessor, + IOptions options) : Interceptor +{ + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false); + IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity); + using (identityScope) + { + return await continuation(request, context).ConfigureAwait(false); + } + } + + public override async Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation) + { + ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false); + IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity); + using (identityScope) + { + await continuation(request, responseStream, context).ConfigureAwait(false); + } + } + + private async Task AuthenticateAndAuthorizeAsync( + TRequest request, + ServerCallContext context) + where TRequest : class + { + if (options.Value.Authentication.Mode == AuthenticationMode.Disabled) + { + return null; + } + + string? authorizationHeader = context.RequestHeaders.GetValue("authorization"); + ApiKeyVerificationResult verificationResult = await apiKeyVerifier + .VerifyAsync(authorizationHeader, context.CancellationToken) + .ConfigureAwait(false); + + if (!verificationResult.Succeeded || verificationResult.Identity is null) + { + throw new RpcException(new Status( + StatusCode.Unauthenticated, + "Missing or invalid API key.")); + } + + string requiredScope = scopeResolver.ResolveRequiredScope(request); + if (!verificationResult.Identity.Scopes.Contains(requiredScope)) + { + throw new RpcException(new Status( + StatusCode.PermissionDenied, + $"API key is missing required scope '{requiredScope}'.")); + } + + return verificationResult.Identity; + } +} diff --git a/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs b/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs new file mode 100644 index 0000000..c315ab4 --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs @@ -0,0 +1,40 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Server.Security.Authorization; + +public sealed class GatewayGrpcScopeResolver +{ + public string ResolveRequiredScope(object request) + { + return request switch + { + OpenSessionRequest => GatewayScopes.SessionOpen, + CloseSessionRequest => GatewayScopes.SessionClose, + StreamEventsRequest => GatewayScopes.EventsRead, + MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified), + _ => GatewayScopes.Admin + }; + } + + private static string ResolveCommandScope(MxCommandKind kind) + { + return kind switch + { + MxCommandKind.Write or + MxCommandKind.Write2 => GatewayScopes.InvokeWrite, + + MxCommandKind.WriteSecured or + MxCommandKind.WriteSecured2 or + MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure, + + MxCommandKind.ArchestraUserToId or + MxCommandKind.GetSessionState or + MxCommandKind.GetWorkerInfo => GatewayScopes.MetadataRead, + + MxCommandKind.DrainEvents => GatewayScopes.EventsRead, + MxCommandKind.ShutdownWorker => GatewayScopes.Admin, + + _ => GatewayScopes.InvokeRead + }; + } +} diff --git a/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs b/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs new file mode 100644 index 0000000..8b9759f --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs @@ -0,0 +1,38 @@ +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Server.Security.Authorization; + +public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAccessor +{ + private readonly AsyncLocal currentIdentity = new(); + + public ApiKeyIdentity? Current => currentIdentity.Value; + + public IDisposable Push(ApiKeyIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + + ApiKeyIdentity? previousIdentity = currentIdentity.Value; + currentIdentity.Value = identity; + + return new IdentityScope(this, previousIdentity); + } + + private sealed class IdentityScope( + GatewayRequestIdentityAccessor accessor, + ApiKeyIdentity? previousIdentity) : IDisposable + { + private bool disposed; + + public void Dispose() + { + if (disposed) + { + return; + } + + accessor.currentIdentity.Value = previousIdentity; + disposed = true; + } + } +} diff --git a/src/MxGateway.Server/Security/Authorization/GatewayScopes.cs b/src/MxGateway.Server/Security/Authorization/GatewayScopes.cs new file mode 100644 index 0000000..f2205da --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/GatewayScopes.cs @@ -0,0 +1,13 @@ +namespace MxGateway.Server.Security.Authorization; + +public static class GatewayScopes +{ + public const string SessionOpen = "session:open"; + public const string SessionClose = "session:close"; + public const string InvokeRead = "invoke:read"; + public const string InvokeWrite = "invoke:write"; + public const string InvokeSecure = "invoke:secure"; + public const string EventsRead = "events:read"; + public const string MetadataRead = "metadata:read"; + public const string Admin = "admin"; +} diff --git a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs new file mode 100644 index 0000000..6de9d56 --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Grpc.Core.Interceptors; + +namespace MxGateway.Server.Security.Authorization; + +public static class GrpcAuthorizationServiceCollectionExtensions +{ + public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddGrpc(options => options.Interceptors.Add()); + + return services; + } +} diff --git a/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs b/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs new file mode 100644 index 0000000..eb87699 --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs @@ -0,0 +1,10 @@ +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Server.Security.Authorization; + +public interface IGatewayRequestIdentityAccessor +{ + ApiKeyIdentity? Current { get; } + + IDisposable Push(ApiKeyIdentity identity); +} diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs new file mode 100644 index 0000000..f75a807 --- /dev/null +++ b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -0,0 +1,267 @@ +using Grpc.Core; +using Microsoft.Extensions.Options; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Security.Authorization; + +public sealed class GatewayGrpcAuthorizationInterceptorTests +{ + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + 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)]); + } + + private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier + { + public bool WasCalled { get; private set; } + + public string? LastAuthorizationHeader { get; private set; } + + public Task VerifyAsync( + string? authorizationHeader, + CancellationToken cancellationToken) + { + WasCalled = true; + LastAuthorizationHeader = authorizationHeader; + + return Task.FromResult(result); + } + } + + private sealed class TestServerStreamWriter : IServerStreamWriter + { + public List Messages { get; } = []; + + public WriteOptions? WriteOptions { get; set; } + + 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(); + } + } +} diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs new file mode 100644 index 0000000..6db403c --- /dev/null +++ b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs @@ -0,0 +1,54 @@ +using MxGateway.Contracts.Proto; +using MxGateway.Server.Security.Authorization; + +namespace MxGateway.Tests.Security.Authorization; + +public sealed class GatewayGrpcScopeResolverTests +{ + [Theory] + [InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)] + [InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)] + [InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)] + public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope( + Type requestType, + string expectedScope) + { + GatewayGrpcScopeResolver resolver = new(); + object request = Activator.CreateInstance(requestType)!; + + string scope = resolver.ResolveRequiredScope(request); + + Assert.Equal(expectedScope, scope); + } + + [Theory] + [InlineData(MxCommandKind.Register, GatewayScopes.InvokeRead)] + [InlineData(MxCommandKind.AddItem, GatewayScopes.InvokeRead)] + [InlineData(MxCommandKind.Advise, GatewayScopes.InvokeRead)] + [InlineData(MxCommandKind.Write, GatewayScopes.InvokeWrite)] + [InlineData(MxCommandKind.Write2, GatewayScopes.InvokeWrite)] + [InlineData(MxCommandKind.WriteSecured, GatewayScopes.InvokeSecure)] + [InlineData(MxCommandKind.WriteSecured2, GatewayScopes.InvokeSecure)] + [InlineData(MxCommandKind.AuthenticateUser, GatewayScopes.InvokeSecure)] + [InlineData(MxCommandKind.ArchestraUserToId, GatewayScopes.MetadataRead)] + [InlineData(MxCommandKind.GetSessionState, GatewayScopes.MetadataRead)] + [InlineData(MxCommandKind.GetWorkerInfo, GatewayScopes.MetadataRead)] + [InlineData(MxCommandKind.DrainEvents, GatewayScopes.EventsRead)] + [InlineData(MxCommandKind.ShutdownWorker, GatewayScopes.Admin)] + public void ResolveRequiredScope_InvokeCommand_ReturnsExpectedScope( + MxCommandKind commandKind, + string expectedScope) + { + GatewayGrpcScopeResolver resolver = new(); + + string scope = resolver.ResolveRequiredScope(new MxCommandRequest + { + Command = new MxCommand + { + Kind = commandKind + } + }); + + Assert.Equal(expectedScope, scope); + } +} -- 2.52.0