feat(security): register TimeProvider + S18/S19 option knobs (archreview 03/S18,S19)

This commit is contained in:
Joseph Doherty
2026-08-20 09:36:04 -04:00
parent dd7f495432
commit 894444376e
9 changed files with 266 additions and 54 deletions
@@ -236,8 +236,10 @@ public sealed class MeshRegistrationRoleScopingTests
}
/// <summary>
/// Pins the scanner's expected result shape against today's Program.cs — 6 markers,
/// 5 braced blocks + the 1 braceless ConfigDb gate, so 6 scanned ranges. Without this, a
/// Pins the scanner's expected result shape against today's Program.cs — 5 markers,
/// 4 braced blocks + the 1 braceless ConfigDb gate, so 5 scanned ranges (the data-plane
/// key-store branch moved into <c>DataPlaneAuthRegistration</c> for arch-review 03/S18, taking
/// one braced marker with it). Without this, a
/// scanner bug that silently drops or merges a marker (as the pre-fix version did to the
/// braceless marker — it fabricated a phantom range spanning an unrelated if-block and swallowed
/// a real nested 'if (hasAdmin)' block inside it, yielding 5 ranges instead of 6) would not
@@ -248,13 +250,13 @@ public sealed class MeshRegistrationRoleScopingTests
{
var source = ReadProgramSource();
CountOccurrences(source, HasAdminMarker).ShouldBe(6,
CountOccurrences(source, HasAdminMarker).ShouldBe(5,
"Program.cs's 'if (hasAdmin)' marker count changed. If a marker was added or removed, " +
"update this pin — and check whether the new/removed occurrence is braced or braceless — " +
"rather than letting the scanner silently mis-shape its result.");
var ranges = FindHasAdminBlockRanges(source);
ranges.Count.ShouldBe(6,
ranges.Count.ShouldBe(5,
"expected one scanned range per 'if (hasAdmin)' marker, including the braceless ConfigDb " +
"gate. A mismatch means the scanner adopted an unrelated brace or otherwise merged/dropped " +
"a marker instead of handling each one independently.");
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Guards the role-conditional data-plane authentication wiring (arch-review 03/S18). A decorator
/// registered in one branch of a role conditional is the "built but never wired" shape, so the branch
/// each node role lands in is asserted rather than assumed.
/// </summary>
public sealed class ApiKeyStoreRegistrationTests
{
/// <summary>
/// The authorizer's clock and the registered clock must be the SAME instance.
/// </summary>
/// <remarks>
/// <see cref="DataPlaneAuthorizer"/> already documents that a divergence between two clocks denies
/// every request with a verdict indistinguishable from "no grant matched" — found once by a test
/// clock two hours ahead. One registered singleton threaded into every consumer makes the
/// divergence unrepresentable instead of merely unlikely, so the registration is what is pinned.
/// </remarks>
[Fact]
public void The_authorizer_and_the_key_store_share_one_TimeProvider()
{
using var sp = BuildNode(hasAdmin: true, hasDriver: true);
var registered = sp.GetRequiredService<TimeProvider>();
var authorizer = sp.GetRequiredService<DataPlaneAuthorizer>();
registered.ShouldBeSameAs(Probe.Instance);
authorizer.Clock.ShouldBeSameAs(registered);
}
/// <summary>Builds the data-plane authentication wiring for one node role combination.</summary>
/// <param name="hasAdmin">Whether the node carries the <c>admin</c> role.</param>
/// <param name="hasDriver">Whether the node carries the <c>driver</c> role.</param>
/// <returns>The built provider; the caller disposes it.</returns>
internal static ServiceProvider BuildNode(bool hasAdmin, bool hasDriver)
{
var services = new ServiceCollection();
services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance));
services.AddSingleton<TimeProvider>(Probe.Instance);
services.Configure<ApiKeyOptions>(_ => { });
services.Configure<AclOptions>(_ => { });
services.Configure<AkkaClusterOptions>(o => o.Roles = ["admin", "driver", "cluster-MAIN"]);
services.AddSingleton<IDataPlaneAuthoritySource>(EmptyDataPlaneAuthoritySource.Instance);
services.AddSingleton<PermissionTrieCache>();
services.AddSingleton<DataPlaneAuthorityState>();
if (hasAdmin)
{
services.AddDbContextFactory<OtOpcUaConfigDbContext>(
o => o.UseInMemoryDatabase($"apikey-reg-{Guid.NewGuid():N}"));
}
services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
return services.BuildServiceProvider();
}
/// <summary>A clock distinguishable from <see cref="TimeProvider.System"/> by reference.</summary>
private sealed class Probe : TimeProvider
{
public static readonly Probe Instance = new();
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
}
}