396 lines
19 KiB
C#
396 lines
19 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Net;
|
|
using Akka.Actor;
|
|
using Akka.Hosting;
|
|
using Grpc.Core;
|
|
using Grpc.Net.Client;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LocalDb.Replication;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
|
using ZB.MOM.WW.OtOpcUa.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// R3-01 T4 — the CI-shaped guard that would have caught 03/S14: mesh gRPC calls through the
|
|
/// REAL <c>AddOtOpcUaAuth</c> + <c>UseAuthentication</c>/<c>UseAuthorization</c> stack, not a
|
|
/// hand-built pipeline that never installs the middleware that broke production.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The load-bearing assertion is the ALLOW.</b> A blocked call and a refused call both
|
|
/// "fail"; only a correctly-keyed call SUCCEEDING through the real cookie
|
|
/// <c>FallbackPolicy</c> proves the <c>.AllowAnonymous()</c> opt-outs (T2/T3) actually work in
|
|
/// a pipeline that carries the policy at all. Every positive test here failed before T2/T3 —
|
|
/// confirmed by running this class against the T1 commit, where every one of them was
|
|
/// challenged by the cookie handler instead of reaching the interceptor.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Negative control (anti-vacuity).</b> <see cref="LocalDbSyncService"/> is mapped directly
|
|
/// here — via a bare <c>MapGrpcService</c>, deliberately NOT through
|
|
/// <c>MapOtOpcUaMeshGrpcServices</c> — with no <c>.AllowAnonymous()</c>. A correctly-shaped
|
|
/// call to it must be refused by the cookie challenge. If it were not, the fixture's auth
|
|
/// stack would be inert and every positive assertion above would be proving nothing.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class MeshGrpcAuthStackTests : IAsyncLifetime
|
|
{
|
|
private const string ConfigServeKey = "test-configserve-key";
|
|
private const string NodeCommandKey = "test-node-psk";
|
|
private const string CentralCommandKey = "test-central-psk";
|
|
private const string TelemetryKey = "test-telemetry-key";
|
|
private const string ClusterId = "MAIN";
|
|
|
|
private WebApplication _app = null!;
|
|
private ActorSystem _system = null!;
|
|
private int _port;
|
|
private TaskCompletionSource<object> _centralInbox = null!;
|
|
private TaskCompletionSource<object> _nodeInbox = null!;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Environment.ApplicationName = typeof(MeshGrpcAuthStackTests).Assembly.GetName().Name!;
|
|
// Production, not the WebApplicationBuilder default: forces ValidateOnBuild/ValidateScopes OFF
|
|
// (they engage only under Development), so services never resolved without a login attempt
|
|
// (ILdapAuthService's graph) don't need to be fully stood up for this fixture to build.
|
|
builder.Environment.EnvironmentName = Environments.Production;
|
|
builder.Logging.ClearProviders();
|
|
|
|
// DataProtection's PersistKeysToDbContext<OtOpcUaConfigDbContext> (inside AddOtOpcUaAuth)
|
|
// resolves a SCOPED OtOpcUaConfigDbContext via CreateScope() + GetRequiredService — it has no
|
|
// factory support. Mirrors AddOtOpcUaConfigDb's own factory+scoped-bridge shape, over InMemory.
|
|
var dbName = Guid.NewGuid().ToString("N");
|
|
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase(dbName));
|
|
builder.Services.AddScoped(sp =>
|
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
|
|
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
// The whole point of this fixture: exercise the REAL cookie handler, not
|
|
// AutoLoginAuthenticationHandler. See CookieSchemeIsTheRealHandler_NotAutoLogin.
|
|
["Security:Auth:DisableLogin"] = "false",
|
|
// This fixture serves cleartext h2c; a HTTPS-only cookie policy is irrelevant to what is
|
|
// under test (the FallbackPolicy applies identically either way) and would otherwise log
|
|
// a startup Warning for no reason.
|
|
["Security:Cookie:RequireHttpsCookie"] = "false",
|
|
});
|
|
|
|
builder.Services.AddOtOpcUaAuth(builder.Configuration);
|
|
|
|
// Interceptor options bound directly (NOT via AddOtOpcUaCluster, which also wires
|
|
// AkkaClusterOptions/ConfigSourceOptions/TelemetryDialOptions validators with ValidateOnStart —
|
|
// heavy machinery this fixture has no use for and no seed-node config to satisfy).
|
|
builder.Services.Configure<ConfigServeOptions>(o => o.ApiKey = ConfigServeKey);
|
|
builder.Services.Configure<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
|
|
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
|
|
builder.Services.AddSingleton<IMeshPskProvider>(new FixedPskProvider(ClusterId, CentralCommandKey));
|
|
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
|
|
|
|
_system = ActorSystem.Create("mesh-auth-stack-" + Guid.NewGuid().ToString("N"));
|
|
_centralInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var registry = new ActorRegistry();
|
|
registry.Register<CentralCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_centralInbox)));
|
|
registry.Register<NodeCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_nodeInbox)));
|
|
builder.Services.AddSingleton(registry);
|
|
|
|
// The production interceptor set — the ONLY inbound auth on the gRPC side. Deliberately does
|
|
// NOT include LocalDbSyncAuthInterceptor: LocalDbSyncService is mapped below purely as the
|
|
// negative control and is never given a matching interceptor here.
|
|
builder.Services.AddGrpc(o =>
|
|
{
|
|
o.Interceptors.Add<ConfigServeAuthInterceptor>();
|
|
o.Interceptors.Add<CentralCommandAuthInterceptor>();
|
|
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
|
|
o.Interceptors.Add<MeshCommandAuthInterceptor>();
|
|
});
|
|
|
|
_port = GetFreePort();
|
|
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
|
|
|
|
_app = builder.Build();
|
|
_app.UseAuthentication();
|
|
_app.UseAuthorization();
|
|
|
|
// The production mapper — the four chainable maps carry AllowAnonymous (T2); LocalDbSync is
|
|
// NOT mapped here (syncListenPort: 0) so it stays free for the negative control below without
|
|
// a route collision.
|
|
_app.MapOtOpcUaMeshGrpcServices(
|
|
hasAdmin: true, hasDriver: true,
|
|
syncListenPort: 0, configServeGrpcPort: 1, telemetryListenPort: 1);
|
|
|
|
// Negative control (T4): mapped directly, NOT through MapOtOpcUaMeshGrpcServices, and
|
|
// deliberately with no .AllowAnonymous().
|
|
_app.MapGrpcService<LocalDbSyncService>();
|
|
|
|
await _app.StartAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _app.StopAsync(TimeSpan.FromSeconds(10));
|
|
await _app.DisposeAsync();
|
|
await _system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CookieSchemeIsTheRealHandler_NotAutoLogin()
|
|
{
|
|
// If this resolves AutoLoginAuthenticationHandler, every assertion below is meaningless — the
|
|
// fallback policy would pass every request regardless of AllowAnonymous.
|
|
var provider = _app.Services.GetRequiredService<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider>();
|
|
var scheme = await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
scheme.ShouldNotBeNull();
|
|
scheme.HandlerType.ShouldBe(typeof(CookieAuthenticationHandler));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CorrectlyKeyed_ArtifactFetch_SucceedsThroughTheRealAuthStack()
|
|
{
|
|
var deploymentId = Guid.NewGuid();
|
|
var blob = "R3-01-T4-artifact-bytes"u8.ToArray();
|
|
var factory = _app.Services.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
|
await using (var db = await factory.CreateDbContextAsync(TestContext.Current.CancellationToken))
|
|
{
|
|
db.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = deploymentId,
|
|
RevisionHash = new string('a', 64),
|
|
CreatedBy = "r3-01-t4",
|
|
ArtifactBlob = blob,
|
|
});
|
|
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
|
|
var headers = new Metadata { { "authorization", $"Bearer {ConfigServeKey}" } };
|
|
|
|
using var call = client.Fetch(
|
|
new FetchRequest { DeploymentId = deploymentId.ToString() },
|
|
headers,
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
using var buffer = new MemoryStream();
|
|
await foreach (var chunk in call.ResponseStream.ReadAllAsync(TestContext.Current.CancellationToken))
|
|
chunk.Data.WriteTo(buffer);
|
|
|
|
buffer.ToArray().ShouldBe(blob);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CorrectlyKeyed_CentralCommandReportApply_SucceedsThroughTheRealAuthStack()
|
|
{
|
|
var ack = new ApplyAck(
|
|
new DeploymentId(Guid.NewGuid()),
|
|
new NodeId("node-a"),
|
|
ApplyAckOutcome.Applied,
|
|
FailureReason: null,
|
|
CorrelationId.NewId());
|
|
|
|
var client = new CentralCommandService.CentralCommandServiceClient(Channel());
|
|
var headers = new Metadata
|
|
{
|
|
{ "authorization", $"Bearer {CentralCommandKey}" },
|
|
{ "x-otopcua-cluster", ClusterId },
|
|
};
|
|
|
|
await client.ReportApplyAsync(
|
|
MeshTransportDtoMapper.ToProto(ack), headers, cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
var received = await _centralInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
|
received.ShouldBeOfType<ApplyAck>().DeploymentId.ShouldBe(ack.DeploymentId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CorrectlyKeyed_NodeCommandExecute_SucceedsThroughTheRealAuthStack()
|
|
{
|
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
|
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
|
|
var headers = new Metadata { { "authorization", $"Bearer {NodeCommandKey}" } };
|
|
|
|
await client.ExecuteAsync(
|
|
MeshTransportDtoMapper.ToEnvelope(command), headers, cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
var received = await _nodeInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
|
received.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FalsifiabilityControl_AnUnAnonymisedServiceMapped_RefusesEvenACorrectlyShapedCall()
|
|
{
|
|
// LocalDbSyncService carries no AllowAnonymous in this fixture (see InitializeAsync). If this
|
|
// call succeeds, the fixture's auth stack is not actually challenging unmarked endpoints and
|
|
// every "succeeds through the real auth stack" assertion above proves nothing.
|
|
var client = new ZB.MOM.WW.LocalDb.Contracts.LocalDbSync.LocalDbSyncClient(Channel());
|
|
var headers = new Metadata { { "authorization", "Bearer irrelevant-to-this-endpoint" } };
|
|
|
|
using var call = client.Sync(headers, cancellationToken: TestContext.Current.CancellationToken);
|
|
await call.RequestStream.CompleteAsync();
|
|
|
|
// Review (R3-01): asserting a bare Exception was vacuous — an anonymised endpoint ALSO throws
|
|
// here (handler deps are not wired in this fixture => RpcException Unknown). Only the
|
|
// cookie-challenge signature discriminates: the FallbackPolicy's 302 to /login is a
|
|
// non-gRPC response, surfaced by grpc-dotnet as Internal + PROTOCOL_ERROR.
|
|
var ex = await Should.ThrowAsync<RpcException>(
|
|
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
|
|
ex.StatusCode.ShouldBe(StatusCode.Internal);
|
|
ex.Status.Detail.ShouldContain("PROTOCOL_ERROR");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// T5 - status fidelity: a no-PSK / wrong-PSK call reports the INTERCEPTOR's verdict, never a
|
|
// cookie challenge. This is the vacuity trap the plan calls out: ConfigServeAuthInterceptor
|
|
// throws StatusCode.Unauthenticated, and Grpc.Net.Client also maps an HTTP 401 to
|
|
// Unauthenticated - so "the call failed with Unauthenticated" is true on a broken build AND a
|
|
// fixed one. The interceptor's DETAIL string is the only thing that discriminates them.
|
|
//
|
|
// Observed cookie-challenge signature (captured red-then-green against this same fixture, see
|
|
// MeshGrpcAuthStackTests' positive tests): the challenge does not surface as a clean
|
|
// Unauthenticated/PermissionDenied at all. Grpc.Net.Client reports
|
|
// RpcException(StatusCode.Internal, "Error starting gRPC call. HttpRequestException: The HTTP/2
|
|
// server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1)..."), because the cookie
|
|
// handler's redirect/401 breaks the prior-knowledge h2c stream before any gRPC trailers are
|
|
// written. So a status-only assertion here would ALSO be vacuous in the other direction: a
|
|
// regressed build fails with StatusCode.Internal, never Unauthenticated/PermissionDenied, and a
|
|
// caller checking only "did it throw" cannot tell a blocked call from a refused one. Recorded
|
|
// here per the plan (T5 step 2) - this is the exact string the live gate (T10) greps for.
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("Bearer the-wrong-key")]
|
|
public async Task NoOrWrongPsk_ArtifactFetch_FailsWithTheInterceptorsDetail(string? authHeader)
|
|
{
|
|
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
|
|
var headers = new Metadata();
|
|
if (authHeader is not null)
|
|
headers.Add("authorization", authHeader);
|
|
|
|
using var call = client.Fetch(
|
|
new FetchRequest { DeploymentId = Guid.NewGuid().ToString() },
|
|
headers,
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
var ex = await Should.ThrowAsync<RpcException>(
|
|
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
|
|
|
|
ex.StatusCode.ShouldBe(StatusCode.Unauthenticated);
|
|
ex.Status.Detail.ShouldBe("Config-serve authentication failed.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("Bearer the-wrong-key")]
|
|
public async Task NoOrWrongPsk_TelemetrySubscribe_FailsWithTheInterceptorsDetail(string? authHeader)
|
|
{
|
|
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
|
|
var headers = new Metadata();
|
|
if (authHeader is not null)
|
|
headers.Add("authorization", authHeader);
|
|
|
|
using var call = client.Subscribe(
|
|
new TelemetryStreamRequest { CorrelationId = "r3-01-t5" },
|
|
headers,
|
|
cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
var ex = await Should.ThrowAsync<RpcException>(
|
|
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
|
|
|
|
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
|
ex.Status.Detail.ShouldBe("Telemetry stream authentication failed.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("Bearer the-wrong-key")]
|
|
public async Task NoOrWrongPsk_NodeCommandExecute_FailsWithTheInterceptorsDetail(string? authHeader)
|
|
{
|
|
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
|
|
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
|
|
var headers = new Metadata();
|
|
if (authHeader is not null)
|
|
headers.Add("authorization", authHeader);
|
|
|
|
var ex = await Should.ThrowAsync<RpcException>(() => client.ExecuteAsync(
|
|
MeshTransportDtoMapper.ToEnvelope(command), headers,
|
|
cancellationToken: TestContext.Current.CancellationToken).ResponseAsync);
|
|
|
|
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
|
|
ex.Status.Detail.ShouldBe("Node command authentication failed.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// harness
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
private GrpcChannel Channel() => GrpcChannel.ForAddress($"http://localhost:{_port}");
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hands out the one cluster key <see cref="CentralCommandAuthInterceptor"/> expects. Stands
|
|
/// in for the real <c>MeshPskProvider</c> (secret-store lookups, caching, the known-cluster
|
|
/// gate) — out of scope here, where the subject is the HTTP-layer auth stack, not the PSK
|
|
/// resolution chain (that is <c>MeshPskProviderTests</c>' job).
|
|
/// </summary>
|
|
private sealed class FixedPskProvider(string clusterId, string psk) : IMeshPskProvider
|
|
{
|
|
public bool TryGetPsk(string clusterId2, [NotNullWhen(true)] out string? psk2)
|
|
{
|
|
psk2 = string.Equals(clusterId2, clusterId, StringComparison.Ordinal) ? psk : null;
|
|
return psk2 is not null;
|
|
}
|
|
|
|
public bool Matches(string clusterId2, string presented) =>
|
|
string.Equals(clusterId2, clusterId, StringComparison.Ordinal)
|
|
&& string.Equals(presented, psk, StringComparison.Ordinal);
|
|
|
|
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds) { }
|
|
|
|
public void Invalidate(string clusterId2) { }
|
|
|
|
public void InvalidateAll() { }
|
|
}
|
|
|
|
/// <summary>Completes an inbox with the first message it receives.</summary>
|
|
private sealed class CaptureActor : ReceiveActor
|
|
{
|
|
public static Props Props(TaskCompletionSource<object> sink) =>
|
|
Akka.Actor.Props.Create(() => new CaptureActor(sink));
|
|
|
|
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
|
|
}
|
|
}
|