From 075c0e69daefcee210871fa89d0894e00fa1293d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 15:25:49 -0400 Subject: [PATCH] feat(audit): OtOpcUa IAuditActorAccessor seam + HTTP impl (audit Actor from Auth principal) (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the IAuditActorAccessor seam and HttpAuditActorAccessor impl so the ZB.MOM.WW.Audit.AuditEvent Actor field can be sourced from the authenticated Blazor cookie principal (ZbClaimTypes.Username) when structured emitters are added. Adds the AuditActor.Resolve static helper (accessor value → SystemFallback/"system") as the canonical pattern for future emit sites. Wires DI in AddOtOpcUaAuth (TryAddScoped) with AddHttpContextAccessor(). The structured AuditEvent path remains DORMANT — no live emit sites exist; seam is forward-looking. SP-based audit path left untouched. 9 new unit tests all green; Security (54) and ControlPlane (45) test suites fully pass. --- .../Audit/AuditActor.cs | 50 ++++++++ .../Audit/HttpAuditActorAccessor.cs | 53 ++++++++ .../Audit/IAuditActorAccessor.cs | 30 +++++ .../ServiceCollectionExtensions.cs | 14 +++ .../Audit/AuditActorTests.cs | 81 ++++++++++++ .../Audit/HttpAuditActorAccessorTests.cs | 115 ++++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuditActor.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/IAuditActorAccessor.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuditActorTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/HttpAuditActorAccessorTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuditActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuditActor.cs new file mode 100644 index 00000000..00781b8f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuditActor.cs @@ -0,0 +1,50 @@ +namespace ZB.MOM.WW.OtOpcUa.Security.Audit; + +/// +/// Default-resolution helpers for the Actor field of a canonical +/// ZB.MOM.WW.Audit.AuditEvent. +/// +/// +/// +/// Usage pattern — call when constructing an AuditEvent: +/// +/// new AuditEvent +/// { +/// Actor = AuditActor.Resolve(auditActorAccessor), +/// ... +/// } +/// +/// +/// +/// Note: OtOpcUa has no live structured AuditEvent emit sites as of Phase 3 +/// (all production audit flows through the bespoke stored-procedure path). This helper is +/// forward-looking — it is tested and ready so that future emit sites pick up the correct +/// Actor automatically. +/// +/// +public static class AuditActor +{ + /// The fallback actor string used when no authenticated principal is available. + public const string SystemFallback = "system"; + + /// + /// Returns the current principal's actor string from , or + /// when the accessor returns + /// (no HTTP context, unauthenticated, or in a background/non-HTTP execution context). + /// + /// The audit-actor accessor. May be + /// (e.g. in a background context where DI did not wire the accessor). + /// The actor string — never . + public static string Resolve(IAuditActorAccessor? accessor) => + Resolve(accessor, SystemFallback); + + /// + /// Returns the current principal's actor string from , or + /// when the accessor returns . + /// + /// The audit-actor accessor. May be . + /// The explicit fallback value. + /// The actor string — never . + public static string Resolve(IAuditActorAccessor? accessor, string fallback) => + accessor?.CurrentActor ?? fallback; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs new file mode 100644 index 00000000..9a86339f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Http; +using ZB.MOM.WW.Auth.AspNetCore; + +namespace ZB.MOM.WW.OtOpcUa.Security.Audit; + +/// +/// HTTP-context–backed for the OtOpcUa control-plane. +/// +/// +/// Reads the authenticated principal from : +/// +/// If there is no current HttpContext or the user is not authenticated, +/// returns . +/// Otherwise, returns the claim value (the +/// canonical directory login name set at sign-in by AuthEndpoints). +/// Falls back to the claim, then to +/// , in that order. +/// +/// +/// Registered as scoped in +/// so that it correctly follows the request scope used by Blazor Server interactive components +/// and minimal-API endpoints. IHttpContextAccessor is registered by +/// AddOtOpcUaAuth via services.AddHttpContextAccessor(). +/// +/// +public sealed class HttpAuditActorAccessor : IAuditActorAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + /// Initializes the accessor with the ASP.NET Core HTTP context accessor. + /// The HTTP context accessor. + public HttpAuditActorAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + public string? CurrentActor + { + get + { + var user = _httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + return null; + + // Prefer the canonical login-name claim; fall back to the Name claim or + // Identity.Name (both of which map to ClaimTypes.Name / ZbClaimTypes.Name). + return user.FindFirst(ZbClaimTypes.Username)?.Value + ?? user.FindFirst(ZbClaimTypes.Name)?.Value + ?? user.Identity.Name; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/IAuditActorAccessor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/IAuditActorAccessor.cs new file mode 100644 index 00000000..ad9f081c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/IAuditActorAccessor.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.OtOpcUa.Security.Audit; + +/// +/// Resolves the current HTTP principal's actor string for inclusion in a canonical +/// ZB.MOM.WW.Audit.AuditEvent as the Actor field. +/// +/// +/// The seam abstracts the identity source so that: +/// +/// production code uses (reads the +/// authenticated Blazor cookie principal from IHttpContextAccessor); and +/// unit tests or non-HTTP contexts can substitute a stub or return +/// (which triggers the "system" fallback in +/// ). +/// +/// +/// Note: OtOpcUa has no live structured AuditEvent emit sites as of Phase 3 +/// (all production audit flows through the bespoke stored-procedure path). This seam is +/// forward-looking — wired and tested so that future emit sites can call +/// and get the Auth principal automatically. +/// +/// +public interface IAuditActorAccessor +{ + /// + /// Returns the authenticated principal's actor string, or when + /// there is no current HTTP context or the user is not authenticated. + /// + string? CurrentActor { get; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 953a286b..f62b7e5f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Security.Audit; using ZB.MOM.WW.OtOpcUa.Security.Jwt; using ZB.MOM.WW.OtOpcUa.Security.Ldap; @@ -37,6 +38,19 @@ public static class ServiceCollectionExtensions services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName)); services.AddSingleton(); + + // IHttpContextAccessor is not registered by default — call AddHttpContextAccessor() + // so HttpAuditActorAccessor and any Blazor/minimal-API component that reads the current + // HTTP context by injection can resolve it. AddHttpContextAccessor is idempotent (internal + // TryAdd), so calling it here is safe even if the host also calls it elsewhere. + services.AddHttpContextAccessor(); + + // IAuditActorAccessor — resolves the authenticated HTTP principal's actor string for use + // as the Actor field when constructing a canonical ZB.MOM.WW.Audit.AuditEvent. Registered + // Scoped so it correctly follows the request scope used by Blazor Server and minimal-API + // endpoints. + services.TryAddScoped(); + // Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it // wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton // LdapOpcUaUserAuthenticator on driver-role nodes. This is the app's ILdapAuthService: it diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuditActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuditActorTests.cs new file mode 100644 index 00000000..19f8a09a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuditActorTests.cs @@ -0,0 +1,81 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Security.Audit; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit; + +/// +/// Unit tests for — the static resolution helper that sources the +/// Actor field of a canonical ZB.MOM.WW.Audit.AuditEvent from the current +/// HTTP principal and falls back to a configurable value when no principal is available. +/// +public sealed class AuditActorTests +{ + /// + /// returns the accessor's value + /// when the accessor returns a non-null string. + /// + [Fact] + public void Resolve_returns_accessor_value_when_present() + { + var accessor = new StubAccessor("alice"); + + AuditActor.Resolve(accessor).ShouldBe("alice"); + } + + /// + /// returns + /// when the accessor returns null + /// (unauthenticated / no HTTP context). + /// + [Fact] + public void Resolve_returns_system_fallback_when_accessor_returns_null() + { + var accessor = new StubAccessor(null); + + AuditActor.Resolve(accessor).ShouldBe(AuditActor.SystemFallback); + } + + /// + /// returns + /// when the accessor reference itself is null + /// (e.g. in a background/non-HTTP context where DI did not inject the accessor). + /// + [Fact] + public void Resolve_returns_system_fallback_when_accessor_is_null() + { + AuditActor.Resolve(null).ShouldBe(AuditActor.SystemFallback); + } + + /// + /// uses the explicit + /// fallback string rather than when the accessor + /// returns null. + /// + [Fact] + public void Resolve_uses_explicit_fallback_when_accessor_returns_null() + { + var accessor = new StubAccessor(null); + + AuditActor.Resolve(accessor, "scheduler").ShouldBe("scheduler"); + } + + /// + /// prefers the accessor's + /// value over the explicit fallback when the accessor returns a non-null string. + /// + [Fact] + public void Resolve_prefers_accessor_value_over_explicit_fallback() + { + var accessor = new StubAccessor("bob"); + + AuditActor.Resolve(accessor, "scheduler").ShouldBe("bob"); + } + + // ── stub ────────────────────────────────────────────────────────────────────── + + private sealed class StubAccessor(string? value) : IAuditActorAccessor + { + public string? CurrentActor { get; } = value; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/HttpAuditActorAccessorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/HttpAuditActorAccessorTests.cs new file mode 100644 index 00000000..ce082d5f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/HttpAuditActorAccessorTests.cs @@ -0,0 +1,115 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Shouldly; +using Xunit; +using ZB.MOM.WW.Auth.AspNetCore; +using ZB.MOM.WW.OtOpcUa.Security.Audit; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit; + +/// +/// Unit tests for . +/// +/// Covers the three cases: +/// +/// Authenticated principal with a claim → +/// returns the username claim value. +/// Authenticated principal with only a / no +/// username claim → falls back to the Name claim. +/// No HTTP context (null) or unauthenticated principal → returns +/// . +/// +/// +/// +public sealed class HttpAuditActorAccessorTests +{ + // ── helpers ────────────────────────────────────────────────────────────────── + + private static IHttpContextAccessor ContextWith(ClaimsPrincipal principal) + { + var context = new DefaultHttpContext { User = principal }; + return new HttpContextAccessorStub(context); + } + + private static IHttpContextAccessor NoContext() => + new HttpContextAccessorStub(null); + + private static ClaimsPrincipal AuthenticatedWith(params Claim[] claims) + { + var identity = new ClaimsIdentity( + claims, + authenticationType: "TestScheme", // non-null authenticationType → IsAuthenticated = true + nameType: ZbClaimTypes.Name, + roleType: ZbClaimTypes.Role); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal Unauthenticated() => + new(new ClaimsIdentity()); // no authenticationType → IsAuthenticated = false + + // ── tests ───────────────────────────────────────────────────────────────────── + + /// + /// An authenticated principal that carries + /// returns exactly that claim value — it is the canonical actor string. + /// + [Fact] + public void Returns_username_claim_for_authenticated_principal() + { + var principal = AuthenticatedWith( + new Claim(ZbClaimTypes.Username, "alice"), + new Claim(ZbClaimTypes.Name, "alice-name"), + new Claim(ZbClaimTypes.DisplayName, "Alice User")); + + var sut = new HttpAuditActorAccessor(ContextWith(principal)); + + sut.CurrentActor.ShouldBe("alice"); + } + + /// + /// When the principal has no claim but does have + /// a claim, the Name claim value is returned as the + /// fallback actor. + /// + [Fact] + public void Falls_back_to_Name_claim_when_Username_claim_is_absent() + { + var principal = AuthenticatedWith( + new Claim(ZbClaimTypes.Name, "bob")); + + var sut = new HttpAuditActorAccessor(ContextWith(principal)); + + sut.CurrentActor.ShouldBe("bob"); + } + + /// + /// An unauthenticated principal (Identity.IsAuthenticated == false) returns null — + /// the caller's fallback (typically ) is used. + /// + [Fact] + public void Returns_null_for_unauthenticated_principal() + { + var sut = new HttpAuditActorAccessor(ContextWith(Unauthenticated())); + + sut.CurrentActor.ShouldBeNull(); + } + + /// + /// When there is no current HttpContext (e.g. background task, actor mailbox + /// worker), returns null. + /// + [Fact] + public void Returns_null_when_no_HttpContext() + { + var sut = new HttpAuditActorAccessor(NoContext()); + + sut.CurrentActor.ShouldBeNull(); + } + + // ── stub ────────────────────────────────────────────────────────────────────── + + private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } = context; + } +}