feat(security): register TimeProvider + S18/S19 option knobs (archreview 03/S18,S19)
This commit is contained in:
@@ -578,3 +578,28 @@ T10 anytime after T5/T8; T11 last (after T5 + T9 merge-ready); T12 anytime
|
|||||||
```
|
```
|
||||||
|
|
||||||
Overall effort: **~1 dev-day code+unit, plus the rig session.** The two HIGH-RISK slices are T2/T3 (authentication path — every new branch must land on deny) and T11 (the gate that proves the clock axis actually reaches a real client). T9 is the largest single task and is the only one that can falsify T8 end to end.
|
Overall effort: **~1 dev-day code+unit, plus the rig session.** The two HIGH-RISK slices are T2/T3 (authentication path — every new branch must land on deny) and T11 (the gate that proves the clock axis actually reaches a real client). T9 is the largest single task and is the only one that can falsify T8 end to end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution notes
|
||||||
|
|
||||||
|
Branch `fix/r3-05-acl-hot-path-expiry`, executed 2026-08-20 off master `dd7f4954`. TDD per task:
|
||||||
|
failing test first, red observed and recorded, then green. T11 (live gate) deliberately left `pending`
|
||||||
|
for the orchestrator.
|
||||||
|
|
||||||
|
### T1 — TimeProvider registration + option knobs
|
||||||
|
|
||||||
|
- **RED observed:** `error CS1061: 'ServiceCollection' does not contain a definition for
|
||||||
|
'AddOtOpcUaDataPlaneAuth'` and `'DataPlaneAuthorizer' does not contain a definition for 'Clock'` —
|
||||||
|
the seam and the assertion surface both absent, as predicted.
|
||||||
|
- **DEVIATION (structural, required by the plan's own test):** the plan says the guard test "builds the
|
||||||
|
service collection for an admin node". `Program.cs` is **top-level statements in an executable**, so no
|
||||||
|
test can drive its wiring. The `DataPlaneAuthorizer` registration and the three-branch key-store /
|
||||||
|
authenticator conditional were therefore **moved verbatim** into a new
|
||||||
|
`Host/Configuration/DataPlaneAuthRegistration.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver)` — the same
|
||||||
|
shape `LocalDbRegistration` already uses — and `Program.cs` now calls it. No branch changed.
|
||||||
|
- **DEVIATION (small):** `DataPlaneAuthorizer` gained a public `Clock` property. The two-clock hazard the
|
||||||
|
class already documents can only be pinned by reference, and `_time` was private.
|
||||||
|
- **Collateral:** `MeshRegistrationRoleScopingTests.HasAdminMarkerScan_MatchesTheKnownShapeOfProgramCs`
|
||||||
|
pins the `if (hasAdmin)` marker count in `Program.cs`; the move took one braced marker with it, so the
|
||||||
|
pin went 6 → 5 (the test's own message asks for exactly that when a marker moves).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T1",
|
"id": "T1",
|
||||||
"subject": "Register TimeProvider in DI (shared by authorizer, key store and revalidation timer) + ApiKeyOptions.StoreTimeoutMs/StoreCacheSeconds + AclOptions.RevalidationIntervalSeconds (03/S18, 03/S19)",
|
"subject": "Register TimeProvider in DI (shared by authorizer, key store and revalidation timer) + ApiKeyOptions.StoreTimeoutMs/StoreCacheSeconds + AclOptions.RevalidationIntervalSeconds (03/S18, 03/S19)",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,4 +42,23 @@ public sealed class AclOptions
|
|||||||
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
|
/// false, which preserves the pre-stage-5 role gates byte-for-byte.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often, in seconds, live monitored items are re-authorized against the clock — in addition
|
||||||
|
/// to on every deployment (arch-review 03/S19).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A subscription is authorized once and then streams, so before this a key that expired by the
|
||||||
|
/// CLOCK kept feeding a live subscription until a deployment happened to advance the generation
|
||||||
|
/// — indefinitely on a fleet that has been deployed once and left running. Every other seam
|
||||||
|
/// re-asks per request and honours <c>ExpiresUtc</c> the moment it passes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>0</c> or negative disables the timer, leaving revalidation deploy-triggered only (the
|
||||||
|
/// pre-R3-05 behaviour). The timer is <b>inert while <see cref="Enabled"/> is false</b>: the
|
||||||
|
/// pass early-returns when ACL enforcement is off.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public int RevalidationIntervalSeconds { get; set; } = 60;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,29 @@ public sealed class ApiKeyOptions
|
|||||||
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
|
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string Pepper { get; set; } = string.Empty;
|
public string Pepper { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard per-call deadline, in milliseconds, on the API-key store lookup that runs inline on OPC UA
|
||||||
|
/// session activation (arch-review 03/S18). Exceeding it <b>denies</b> the activation and emits a
|
||||||
|
/// distinct Warning plus <c>otopcua.opcua.apikey_store.bounded{outcome=timeout}</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>0</c> or negative disables the deadline, restoring the pre-R3-05 unbounded behaviour. Do not
|
||||||
|
/// do that on a node with a ConfigDb: the SDK raises impersonation synchronously under a
|
||||||
|
/// <c>SessionManager</c>-wide lock, so an unanswered lookup stalls <b>every</b> session activation
|
||||||
|
/// in the server, not just its own.
|
||||||
|
/// </remarks>
|
||||||
|
public int StoreTimeoutMs { get; set; } = 2000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TTL, in seconds, of the read-through cache in front of the API-key store (arch-review 03/S18).
|
||||||
|
/// Bounds how long a revocation authored in the ConfigDb can take to reach a <b>new</b> session
|
||||||
|
/// activation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>0</c> or negative disables the cache, restoring a store read per activation. The window it
|
||||||
|
/// introduces is strictly smaller than the one that already exists for <b>already-open</b> sessions
|
||||||
|
/// on every node kind, which is the next deployment — see <c>docs/security.md</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public int StoreCacheSeconds { get; set; } = 5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
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.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composition of the OPC UA data-plane authentication + authorization seams (#520), extracted from
|
||||||
|
/// <c>Program.cs</c> so the role-conditional branches are reachable from a test.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A store decorator registered in one branch of a role conditional is exactly the
|
||||||
|
/// "built but never wired" shape, and <c>Program.cs</c>'s top-level statements cannot be driven by a
|
||||||
|
/// test — so the wiring moved here rather than the guard being written against a comment. The
|
||||||
|
/// branches are unchanged from the ones that lived inline; see <c>ApiKeyStoreRegistrationTests</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public static class DataPlaneAuthRegistration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the shared clock, the node-scoped ACL gate and the role-appropriate API-key store +
|
||||||
|
/// user authenticator.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="hasAdmin">Whether this node carries the <c>admin</c> role (and therefore a ConfigDb).</param>
|
||||||
|
/// <param name="hasDriver">Whether this node carries the <c>driver</c> role.</param>
|
||||||
|
/// <returns>The same collection, for chaining.</returns>
|
||||||
|
public static IServiceCollection AddOtOpcUaDataPlaneAuth(
|
||||||
|
this IServiceCollection services, bool hasAdmin, bool hasDriver)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
// ONE clock for the whole authorization surface. The authorizer, the key-store bound and the
|
||||||
|
// revalidation timer all read time, and DataPlaneAuthorizer already documents what a second,
|
||||||
|
// diverging clock costs: every request denied, with a verdict indistinguishable from "no grant
|
||||||
|
// matched". TryAdd so a test (or a future host) can substitute one before this runs.
|
||||||
|
services.TryAddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
|
// #520 stage 5 — the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
||||||
|
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
|
||||||
|
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
|
||||||
|
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations below
|
||||||
|
// both carry a comment about; an absent registration is a node that will not start.
|
||||||
|
services.AddSingleton<IPermissionEvaluator>(sp =>
|
||||||
|
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
|
||||||
|
services.AddSingleton(sp => new DataPlaneAuthorizer(
|
||||||
|
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
|
||||||
|
sp.GetRequiredService<IPermissionEvaluator>(),
|
||||||
|
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
|
||||||
|
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>(),
|
||||||
|
sp.GetRequiredService<TimeProvider>()));
|
||||||
|
|
||||||
|
if (hasAdmin)
|
||||||
|
{
|
||||||
|
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
||||||
|
// on the very next activation rather than on the next deployment. Preferred wherever it is
|
||||||
|
// available, including on a fused admin+driver node.
|
||||||
|
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
|
||||||
|
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
|
||||||
|
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
|
||||||
|
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
|
||||||
|
// rule is what keeps the redundancy-singleton registration in Program.cs from recursing into
|
||||||
|
// ActorSystem construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter.
|
||||||
|
services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||||
|
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
|
||||||
|
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||||
|
|
||||||
|
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
||||||
|
services.AddSingleton<IApiKeyAdminService>(sp => new ApiKeyAdminService(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||||
|
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
|
||||||
|
}
|
||||||
|
else if (hasDriver)
|
||||||
|
{
|
||||||
|
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
|
||||||
|
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
|
||||||
|
// token — safe, but it meant no machine client could authenticate against a site node at all.
|
||||||
|
// Deliberately NOT wrapped by the S18 bound: the artifact store is two dictionary probes on an
|
||||||
|
// in-memory snapshot, so it can neither hang nor be worth caching.
|
||||||
|
services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
|
||||||
|
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
|
||||||
|
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
|
||||||
|
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
|
||||||
|
services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -398,55 +398,10 @@ if (hasDriver)
|
|||||||
builder.Services.AddSingleton<PermissionTrieCache>();
|
builder.Services.AddSingleton<PermissionTrieCache>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// #520 stage 5 — the node-scoped ACL gate. Registered on EVERY node, not just driver ones, because
|
// #520 + arch-review 03/S18 — the node-scoped ACL gate, the shared clock and the role-appropriate
|
||||||
// OtOpcUaServerHostedService takes it as a constructor parameter and the built-in container resolves
|
// API-key store + user authenticator, all in one extension so the role-conditional branches are
|
||||||
// constructors by whether every parameter is registered, not by whether the parameter type is nullable.
|
// reachable from a test (Program.cs's top-level statements are not). See DataPlaneAuthRegistration.
|
||||||
// That is the same trap the IOpcUaUserAuthenticator and IDataPlaneAuthoritySource registrations above
|
builder.Services.AddOtOpcUaDataPlaneAuth(hasAdmin, hasDriver);
|
||||||
// both carry a comment about; an absent registration is a node that will not start.
|
|
||||||
builder.Services.AddSingleton<IPermissionEvaluator>(sp =>
|
|
||||||
new TriePermissionEvaluator(sp.GetRequiredService<PermissionTrieCache>()));
|
|
||||||
builder.Services.AddSingleton(sp => new DataPlaneAuthorizer(
|
|
||||||
sp.GetRequiredService<IDataPlaneAuthoritySource>(),
|
|
||||||
sp.GetRequiredService<IPermissionEvaluator>(),
|
|
||||||
sp.GetRequiredService<IOptions<AclOptions>>().Value.Enabled,
|
|
||||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger<DataPlaneAuthorizer>()));
|
|
||||||
|
|
||||||
if (hasAdmin)
|
|
||||||
{
|
|
||||||
// A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect
|
|
||||||
// on the very next request rather than on the next deployment. Preferred wherever it is available,
|
|
||||||
// including on a fused admin+driver node.
|
|
||||||
// The ClusterId confines the grants this node honours to its OWN cluster — the ConfigDb holds
|
|
||||||
// the whole fleet's ACL rows, unlike the artifact a driver-only node reads, which
|
|
||||||
// ParseDataPlaneAuthority already scoped. Taken from AkkaClusterOptions (pure configuration),
|
|
||||||
// NOT from IClusterRoleInfo: that depends on the ActorSystem, and the same options-not-services
|
|
||||||
// rule is what keeps the redundancy-singleton registration below from recursing into ActorSystem
|
|
||||||
// construction. Null on a node with no cluster-{ClusterId} role ⇒ no filter (legacy behaviour).
|
|
||||||
builder.Services.AddSingleton<IApiKeyStore>(sp => new ConfigDbApiKeyStore(
|
|
||||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
|
||||||
ConfigDbApiKeyStore.ResolveClusterId(sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)));
|
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
|
||||||
|
|
||||||
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
|
|
||||||
builder.Services.AddSingleton<IApiKeyAdminService>(sp => new ApiKeyAdminService(
|
|
||||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
|
||||||
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value.Pepper));
|
|
||||||
}
|
|
||||||
else if (hasDriver)
|
|
||||||
{
|
|
||||||
// Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact.
|
|
||||||
// Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName
|
|
||||||
// token — safe, but it meant no machine client could authenticate against a site node at all.
|
|
||||||
builder.Services.AddSingleton<IApiKeyStore, ArtifactApiKeyStore>();
|
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent —
|
|
||||||
// OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so
|
|
||||||
// an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start.
|
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator>(NullOpcUaUserAuthenticator.Instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
||||||
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
|
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
|
||||||
|
|||||||
@@ -108,6 +108,15 @@ public sealed class DataPlaneAuthorizer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; }
|
public bool Enabled { get; }
|
||||||
|
|
||||||
|
/// <summary>The clock this gate evaluates key expiry against.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Exposed so the composition root's wiring can be asserted by reference. The remarks on
|
||||||
|
/// <c>UserAuthorizationState</c> record what a second, diverging clock costs: every request denied,
|
||||||
|
/// with a verdict indistinguishable from "no grant matched". One registered singleton threaded into
|
||||||
|
/// every consumer is the fix, and a test that cannot see which instance landed here cannot pin it.
|
||||||
|
/// </remarks>
|
||||||
|
public TimeProvider Clock => _time;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
/// Memoized grant walks for the request-rate seams (#520 stage 6). See
|
||||||
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
/// <see cref="SessionAclDecisionCache"/> for what it is and is not allowed to cache.
|
||||||
|
|||||||
+6
-4
@@ -236,8 +236,10 @@ public sealed class MeshRegistrationRoleScopingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pins the scanner's expected result shape against today's Program.cs — 6 markers,
|
/// Pins the scanner's expected result shape against today's Program.cs — 5 markers,
|
||||||
/// 5 braced blocks + the 1 braceless ConfigDb gate, so 6 scanned ranges. Without this, a
|
/// 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
|
/// 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
|
/// 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
|
/// 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();
|
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, " +
|
"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 — " +
|
"update this pin — and check whether the new/removed occurrence is braced or braceless — " +
|
||||||
"rather than letting the scanner silently mis-shape its result.");
|
"rather than letting the scanner silently mis-shape its result.");
|
||||||
|
|
||||||
var ranges = FindHasAdminBlockRanges(source);
|
var ranges = FindHasAdminBlockRanges(source);
|
||||||
ranges.Count.ShouldBe(6,
|
ranges.Count.ShouldBe(5,
|
||||||
"expected one scanned range per 'if (hasAdmin)' marker, including the braceless ConfigDb " +
|
"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 " +
|
"gate. A mismatch means the scanner adopted an unrelated brace or otherwise merged/dropped " +
|
||||||
"a marker instead of handling each one independently.");
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user