Issue #8: add grpc authentication and scope authorization
This commit is contained in:
@@ -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
|
results distinguish malformed credentials, missing keys, revoked keys, missing
|
||||||
pepper configuration, and hash mismatch for internal authorization handling.
|
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:
|
Recommended scopes:
|
||||||
|
|
||||||
- `session:open`
|
- `session:open`
|
||||||
@@ -677,6 +686,20 @@ Commands requiring authorization:
|
|||||||
- worker shutdown diagnostics,
|
- worker shutdown diagnostics,
|
||||||
- metadata queries if they expose sensitive plant structure.
|
- 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
|
### Worker IPC
|
||||||
|
|
||||||
Named pipes should be local only. Pipe ACLs should restrict access to:
|
Named pipes should be local only. Pipe ACLs should restrict access to:
|
||||||
@@ -819,6 +842,9 @@ workers and fake transports.
|
|||||||
Focused tests:
|
Focused tests:
|
||||||
|
|
||||||
- session state transitions,
|
- 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,
|
- worker startup failures,
|
||||||
- protocol version mismatch,
|
- protocol version mismatch,
|
||||||
- malformed frame handling,
|
- malformed frame handling,
|
||||||
|
|||||||
+7
-3
@@ -566,9 +566,13 @@ Because each client owns one worker, a crash or leak affects only that session.
|
|||||||
External gateway:
|
External gateway:
|
||||||
|
|
||||||
- use TLS for remote gRPC if crossing machine boundaries,
|
- use TLS for remote gRPC if crossing machine boundaries,
|
||||||
- authenticate clients with Windows auth, mTLS, or a deployment-specific token,
|
- authenticate v1 gRPC clients with `authorization: Bearer
|
||||||
- authorize access to commands that can write, authenticate users, or alter
|
mxgw_<key-id>_<secret>` API-key metadata,
|
||||||
runtime state.
|
- 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:
|
Internal worker IPC:
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using MxGateway.Server.Configuration;
|
|||||||
using MxGateway.Server.Diagnostics;
|
using MxGateway.Server.Diagnostics;
|
||||||
using MxGateway.Server.Metrics;
|
using MxGateway.Server.Metrics;
|
||||||
using MxGateway.Server.Security.Authentication;
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
using MxGateway.Server.Workers;
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
namespace MxGateway.Server;
|
namespace MxGateway.Server;
|
||||||
@@ -26,6 +27,7 @@ public static class GatewayApplication
|
|||||||
|
|
||||||
builder.Services.AddGatewayConfiguration();
|
builder.Services.AddGatewayConfiguration();
|
||||||
builder.Services.AddSqliteAuthStore();
|
builder.Services.AddSqliteAuthStore();
|
||||||
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddSingleton<GatewayMetrics>();
|
builder.Services.AddSingleton<GatewayMetrics>();
|
||||||
builder.Services.AddWorkerProcessLauncher();
|
builder.Services.AddWorkerProcessLauncher();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -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<GatewayOptions> options) : Interceptor
|
||||||
|
{
|
||||||
|
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||||
|
TRequest request,
|
||||||
|
ServerCallContext context,
|
||||||
|
UnaryServerMethod<TRequest, TResponse> 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, TResponse>(
|
||||||
|
TRequest request,
|
||||||
|
IServerStreamWriter<TResponse> responseStream,
|
||||||
|
ServerCallContext context,
|
||||||
|
ServerStreamingServerMethod<TRequest, TResponse> 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<ApiKeyIdentity?> AuthenticateAndAuthorizeAsync<TRequest>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAccessor
|
||||||
|
{
|
||||||
|
private readonly AsyncLocal<ApiKeyIdentity?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
+16
@@ -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<GatewayGrpcScopeResolver>();
|
||||||
|
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
||||||
|
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
||||||
|
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public interface IGatewayRequestIdentityAccessor
|
||||||
|
{
|
||||||
|
ApiKeyIdentity? Current { get; }
|
||||||
|
|
||||||
|
IDisposable Push(ApiKeyIdentity identity);
|
||||||
|
}
|
||||||
+267
@@ -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<RpcException>(
|
||||||
|
() => 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<RpcException>(
|
||||||
|
() => 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<RpcException>(
|
||||||
|
() => 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<RpcException>(
|
||||||
|
() => interceptor.ServerStreamingServerHandler(
|
||||||
|
new StreamEventsRequest(),
|
||||||
|
new TestServerStreamWriter<MxEvent>(),
|
||||||
|
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<MxEvent> 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<string>(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<ApiKeyVerificationResult> VerifyAsync(
|
||||||
|
string? authorizationHeader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WasCalled = true;
|
||||||
|
LastAuthorizationHeader = authorizationHeader;
|
||||||
|
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
|
||||||
|
{
|
||||||
|
public List<T> 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<object, object> 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<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
protected override IDictionary<object, object> UserStateCore => userState;
|
||||||
|
|
||||||
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||||
|
ContextPropagationOptions? options)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user