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; + } +}