Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11de14d12e | |||
| aadbf49678 | |||
| 70d764b063 | |||
| 11bcff6af5 | |||
| de41963587 | |||
| a78b212c95 | |||
| 075c0e69da | |||
| b7f5e887ee | |||
| 933dd1a874 | |||
| c1619d95f5 | |||
| 8ba289f975 | |||
| d0777eee29 | |||
| 83856b7c27 | |||
| c4f315ec90 | |||
| 257caa7bd1 | |||
| 6534875476 | |||
| d2d7730830 | |||
| 2844180865 | |||
| d3ab2bfbaf | |||
| 88e773af36 | |||
| f35ebd7aaf | |||
| 0cbb82e466 | |||
| 7b6884031d | |||
| 7ff7a60ae0 | |||
| 8faa2bf23d | |||
| 2099713ed8 | |||
| c05ffc7b39 | |||
| 60017177cb | |||
| 26bae36f8b | |||
| 368390ea9d | |||
| 8f950722c6 | |||
| 1d729fb0f8 | |||
| 0b99aceacb | |||
| d57b42bcd6 | |||
| 5e87f7e16f |
@@ -42,3 +42,9 @@ config_cache*.db
|
||||
|
||||
# Client CLI/UI runtime scratch (last-connected endpoint cache)
|
||||
session.dat
|
||||
|
||||
# Secrets / local credentials — never commit
|
||||
sql_login.txt
|
||||
|
||||
# OPC UA certificate store (runtime PKI: own/trusted/issued/rejected certs + keys)
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/
|
||||
|
||||
@@ -79,11 +79,11 @@
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageVersion Include="Polly.Core" Version="8.6.6" />
|
||||
<PackageVersion Include="S7netplus" Version="0.20.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
@@ -96,7 +96,18 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
<PackageVersion Include="xunit.v3" Version="1.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,7 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
<add key="local-mxgw" value="./nuget-packages" />
|
||||
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
<packageSource key="local-mxgw">
|
||||
<package pattern="ZB.MOM.WW.MxGateway.*" />
|
||||
</packageSource>
|
||||
<packageSource key="dohertj2-gitea">
|
||||
<package pattern="ZB.MOM.WW.Health" />
|
||||
<package pattern="ZB.MOM.WW.Health.*" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||
<package pattern="ZB.MOM.WW.Configuration" />
|
||||
<package pattern="ZB.MOM.WW.Auth" />
|
||||
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||
<package pattern="ZB.MOM.WW.Audit" />
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
> **Status (2026-05-29): alarm-source leg VERIFIED. Historian-write leg still
|
||||
> pending the Windows sidecar + live AVEVA Historian.**
|
||||
>
|
||||
> **Re-confirmed 2026-05-31** against the same gateway (`http://10.100.0.48:5120`):
|
||||
> the Skip-gated live test passed again, pulling a native `Raise` transition
|
||||
> (`Galaxy!TestArea.TestMachine_001.TestAlarm001`, raw sev 500 → OPC UA 750/High,
|
||||
> category `TestArea`, operator comment `Test alarm #1`) through the production
|
||||
> consumer. Independent re-run, not the original capture.
|
||||
>
|
||||
> This is the D.1 deliverable called for by `docs/plans/alarms-worker-wiring-plan.md`
|
||||
> — captured evidence that a live Galaxy alarm reaches lmxopcua through the native
|
||||
> gateway path (not the sub-attribute fallback). It supersedes the "A.2 blocked"
|
||||
|
||||
@@ -65,7 +65,7 @@ Running record of v2 dev services on the Windows dev VM. Updated on every instal
|
||||
|---------|---------------------|---------|-----------|------------------------|---------------|--------|
|
||||
| **Central config DB** | Docker container `otopcua-mssql` on the Linux Docker host (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `10.100.0.35:14330` → `1433` (container) — port 14330 retained from the previous local-container setup so connection-string ports don't churn | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` on the Docker host | ✅ Running on Docker host (`/opt/otopcua-mssql/`) since 2026-04-28; carries `project=lmxopcua` label |
|
||||
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box — full ArchestrA + Historian + OI-Server stack | v1 baseline | Local COM via MXAccess (`C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`); Historian via `aaH*` services; SuiteLink via `slssvc` | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ **Fully available — Phase 2 lift unblocked.** 27 ArchestrA / AVEVA / Wonderware services running incl. `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`, `AutoBuild_Service`; full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `aahSupervisor`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `InSQLManualStorage`, `InSQLSystemDriver`, `HistorianSearch-x64`); `slssvc` (Wonderware SuiteLink); `OI-Gateway` install present at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (decision #142 AppServer-via-OI-Gateway smoke test now also unblocked) |
|
||||
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=lmxopcua,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. v2-rebrand to `dc=otopcua,dc=local` is a future cosmetic change |
|
||||
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=zb,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. Dev base DN unified to `dc=zb,dc=local` (Task 1.6) |
|
||||
| OPC Foundation reference server | Not yet built | — | `10.100.0.35:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
|
||||
| FOCAS TCP stub | Not yet built | — | `10.100.0.35:8193` (target) | n/a | — | Pending (built in Phase 5; runs on Docker host) |
|
||||
| Modbus simulator (`otopcua-pymodbus:3.13.0`) | Docker compose at `/opt/otopcua-modbus/` on Docker host | pinned 3.13.0 | `10.100.0.35:5020` | n/a | n/a | Stack staged; bring up with `lmxopcua-fix up modbus <profile>` from this VM |
|
||||
|
||||
@@ -104,8 +104,8 @@ Anonymous OPC UA sessions are denied writes against `Operate`-classified tags by
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"GroupToRole": {
|
||||
"ReadOnly": "ReadOnly",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster-broadcast audit event consumed by the <c>AuditWriterActor</c> singleton, which
|
||||
/// batches and idempotently inserts into <c>ConfigAuditLog</c>.
|
||||
/// </summary>
|
||||
public sealed record AuditEvent(
|
||||
Guid EventId,
|
||||
string Category,
|
||||
string Action,
|
||||
string Actor,
|
||||
DateTime OccurredAtUtc,
|
||||
string? DetailsJson,
|
||||
NodeId SourceNode,
|
||||
CorrelationId CorrelationId);
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -41,4 +41,10 @@ public sealed class ConfigAuditLog
|
||||
/// <summary>Correlation ID from <c>AuditEvent.CorrelationId</c> so an audit row joins to its
|
||||
/// originating request/workflow. Nullable for the same backfill reason as <see cref="EventId"/>.</summary>
|
||||
public Guid? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>Normalized outcome from <c>AuditEvent.Outcome</c> (the canonical
|
||||
/// <c>ZB.MOM.WW.Audit.AuditOutcome</c>: <c>Success</c> | <c>Failure</c> | <c>Denied</c>),
|
||||
/// stored as its enum member name. Nullable so pre-Outcome rows backfill cleanly and the
|
||||
/// bespoke stored-procedure audit path (which does not derive an outcome) writes NULL.</summary>
|
||||
public string? Outcome { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,20 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
/// <see cref="Entities.NodeAcl"/> joined against LDAP group memberships directly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// <para>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Task 1.7 standardized the member names on the canonical control-plane role vocabulary
|
||||
/// (<c>ZB.MOM.WW.Auth</c> <c>CanonicalRole</c>): <c>ConfigViewer → Viewer</c>,
|
||||
/// <c>ConfigEditor → Designer</c>, <c>FleetAdmin → Administrator</c>. The appsettings-only
|
||||
/// <c>DriverOperator</c> string role likewise became <c>Operator</c>. These members persist
|
||||
/// as their string names (EF <c>HasConversion<string></c>); the rename is paired with
|
||||
/// a data migration (<c>CanonicalizeAdminRoles</c>) that rewrites existing rows. This is a
|
||||
/// rename, not a permission change — enforcement semantics are preserved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum AdminRole
|
||||
{
|
||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history.</summary>
|
||||
ConfigViewer,
|
||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history. (Canonical: Viewer; was ConfigViewer.)</summary>
|
||||
Viewer,
|
||||
|
||||
/// <summary>Can author drafts + submit for publish.</summary>
|
||||
ConfigEditor,
|
||||
/// <summary>Can author drafts + submit for publish. (Canonical: Designer; was ConfigEditor.)</summary>
|
||||
Designer,
|
||||
|
||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions.</summary>
|
||||
FleetAdmin,
|
||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions. (Canonical: Administrator; was FleetAdmin.)</summary>
|
||||
Administrator,
|
||||
}
|
||||
|
||||
+1755
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Task 1.7 — canonicalizes the control-plane admin role VALUES persisted in the
|
||||
/// <c>LdapGroupRoleMapping.Role</c> column. The column stores the <c>AdminRole</c> enum
|
||||
/// member name as a string (EF <c>HasConversion<string></c>, <c>nvarchar(32)</c>);
|
||||
/// renaming the enum members (<c>ConfigViewer → Viewer</c>, <c>ConfigEditor → Designer</c>,
|
||||
/// <c>FleetAdmin → Administrator</c>) therefore requires rewriting existing rows so the C#
|
||||
/// enum and the stored strings stay in sync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a pure DATA migration: the schema (column type, length, indexes) is unchanged,
|
||||
/// so the model snapshot is byte-identical to the prior migration. The new canonical strings
|
||||
/// ("Viewer" = 6, "Designer" = 8, "Administrator" = 13 chars) all fit the existing
|
||||
/// <c>nvarchar(32)</c> column. Enforcement semantics are preserved — it is a rename only.
|
||||
/// </remarks>
|
||||
public partial class CanonicalizeAdminRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Viewer' WHERE [Role] = N'ConfigViewer';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Designer' WHERE [Role] = N'ConfigEditor';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Administrator' WHERE [Role] = N'FleetAdmin';");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'FleetAdmin' WHERE [Role] = N'Administrator';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigEditor' WHERE [Role] = N'Designer';");
|
||||
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigViewer' WHERE [Role] = N'Viewer';");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1759
File diff suppressed because it is too large
Load Diff
+35
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Task 2.2 — adds the nullable <c>Outcome</c> column to <c>ConfigAuditLog</c> for the
|
||||
/// canonical <c>ZB.MOM.WW.Audit.AuditOutcome</c> (stored as its enum member name,
|
||||
/// <c>nvarchar(16)</c>, mirroring how <c>AdminRole</c> is persisted). Purely additive:
|
||||
/// nullable with no backfill, so existing rows and the bespoke stored-procedure audit
|
||||
/// path (which does not derive an outcome) keep writing NULL.
|
||||
/// </summary>
|
||||
public partial class AddConfigAuditLogOutcome : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Outcome",
|
||||
table: "ConfigAuditLog",
|
||||
type: "nvarchar(16)",
|
||||
maxLength: 16,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Outcome",
|
||||
table: "ConfigAuditLog");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -186,6 +186,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("Principal")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
|
||||
@@ -445,6 +445,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.EventId);
|
||||
e.Property(x => x.CorrelationId);
|
||||
// Stored as the AuditOutcome enum member name (mirrors AdminRole's string storage):
|
||||
// "Success" | "Failure" | "Denied" all fit nvarchar(16). Nullable for legacy + SP-path rows.
|
||||
e.Property(x => x.Outcome).HasMaxLength(16);
|
||||
|
||||
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
|
||||
.IsDescending(false, true)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Shared query for the cluster-scoped audit view. Audit rows reach <c>ConfigAuditLog</c> by two
|
||||
/// paths that stamp different columns:
|
||||
/// <list type="bullet">
|
||||
/// <item>the bespoke stored-procedure path stamps <c>ClusterId</c> directly;</item>
|
||||
/// <item>the structured <c>AuditWriterActor</c> path stamps <c>NodeId</c> (leaving
|
||||
/// <c>ClusterId</c> null).</item>
|
||||
/// </list>
|
||||
/// A cluster-scoped view must surface both, so this query matches rows whose <c>ClusterId</c>
|
||||
/// equals the cluster <em>or</em> whose <c>NodeId</c> belongs to a node in the cluster
|
||||
/// (membership from <see cref="ClusterNode"/>: <c>NodeId → ClusterId</c>).
|
||||
/// </summary>
|
||||
public static class ClusterAuditQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the newest <paramref name="pageSize"/> audit rows visible for
|
||||
/// <paramref name="clusterId"/>, newest first. Executes one query to resolve the cluster's
|
||||
/// node IDs, then one filtered query against <c>ConfigAuditLog</c>.
|
||||
/// </summary>
|
||||
/// <param name="db">The config database context.</param>
|
||||
/// <param name="clusterId">The cluster whose audit rows to fetch.</param>
|
||||
/// <param name="pageSize">Maximum number of rows to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The matching audit rows, newest first.</returns>
|
||||
public static async Task<List<ConfigAuditLog>> ForClusterAsync(
|
||||
OtOpcUaConfigDbContext db, string clusterId, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var nodeIds = await db.ClusterNodes.AsNoTracking()
|
||||
.Where(n => n.ClusterId == clusterId)
|
||||
.Select(n => n.NodeId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return await db.ConfigAuditLogs.AsNoTracking()
|
||||
.Where(a => a.ClusterId == clusterId
|
||||
|| (a.ClusterId == null && a.NodeId != null && nodeIds.Contains(a.NodeId)))
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,14 @@
|
||||
<title>OtOpcUa Admin</title>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/theme.css"/>
|
||||
<ThemeHead />
|
||||
<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/site.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/nav-state.js"></script>
|
||||
<ThemeScripts />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using System.Security.Claims
|
||||
|
||||
@* Layout chrome ported from ScadaLink CentralUI: no separate top bar — brand sits
|
||||
at the top of the side rail. The sidebar itself is the interactive island
|
||||
(<NavSidebar/>); MainLayout stays statically rendered so the Body RenderFragment
|
||||
doesn't have to cross an interactive boundary. *@
|
||||
@* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns
|
||||
the brand bar, the CSS-only narrow-viewport hamburger, and the responsive collapse,
|
||||
so MainLayout no longer carries its own .app-shell / hamburger wrapper. Nav sections
|
||||
are static <details> (NavRailSection) whose expand state is persisted to localStorage
|
||||
by the kit's <ThemeScripts/> (emitted in App.razor) — replacing the old interactive
|
||||
NavSidebar island + cookie/URL auto-expand. *@
|
||||
|
||||
<div class="app-shell d-flex flex-column flex-lg-row">
|
||||
@* Hamburger toggle: visible only on viewports <lg.
|
||||
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#sidebar-collapse"
|
||||
aria-controls="sidebar-collapse"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||
<NavSidebar />
|
||||
</div>
|
||||
|
||||
<main class="page">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
<ThemeShell Product="OtOpcUa" Accent="#2f5fd0">
|
||||
<Nav>
|
||||
<NavRailSection Title="Navigation" Key="nav">
|
||||
<NavRailItem Href="/" Text="Overview" Match="NavLinkMatch.All" />
|
||||
<NavRailItem Href="/fleet" Text="Fleet status" />
|
||||
<NavRailItem Href="/hosts" Text="Host status" />
|
||||
<NavRailItem Href="/clusters" Text="Clusters" />
|
||||
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Scripting" Key="scripting">
|
||||
<NavRailItem Href="/virtual-tags" Text="Virtual tags" />
|
||||
<NavRailItem Href="/scripted-alarms" Text="Scripted alarms" />
|
||||
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||
<NavRailItem Href="/script-log" Text="Script log" />
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Live" Key="live">
|
||||
<NavRailItem Href="/deployments" Text="Deployments" />
|
||||
<NavRailItem Href="/alerts" Text="Alerts" />
|
||||
<NavRailItem Href="/alarms-historian" Text="Alarms historian" />
|
||||
</NavRailSection>
|
||||
</Nav>
|
||||
<RailFooter>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
||||
<div class="rail-roles">
|
||||
@string.Join(", ", context.User.Claims
|
||||
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value))
|
||||
</div>
|
||||
<form method="post" action="/auth/logout">
|
||||
<AntiforgeryToken />
|
||||
<button class="rail-btn" type="submit">Sign out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-btn" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</RailFooter>
|
||||
<ChildContent>@Body</ChildContent>
|
||||
</ThemeShell>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
@* A collapsible sidebar nav section: an uppercase-eyebrow button that toggles
|
||||
the visibility of its child nav items. Mirrors the ScadaLink NavSection at
|
||||
/Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
|
||||
but uses OtOpcUa's rail-eyebrow + rail-link classes. *@
|
||||
|
||||
<button type="button"
|
||||
class="rail-eyebrow-toggle"
|
||||
@onclick="OnToggle"
|
||||
aria-expanded="@(Expanded ? "true" : "false")">
|
||||
<span class="rail-eyebrow-chevron">@(Expanded ? "▼" : "▶")</span>
|
||||
<span class="rail-eyebrow-label">@Title</span>
|
||||
</button>
|
||||
@if (Expanded)
|
||||
{
|
||||
<div class="rail-section-body">
|
||||
@ChildContent
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>Section label shown in the eyebrow (e.g. "Scripting").</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whether the section is expanded — its child links rendered.</summary>
|
||||
[Parameter]
|
||||
public bool Expanded { get; set; }
|
||||
|
||||
/// <summary>Raised when the eyebrow button is clicked.</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnToggle { get; set; }
|
||||
|
||||
/// <summary>The section's child nav links, rendered only while expanded.</summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
@rendermode InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.JSInterop
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@* Interactive sidebar — extracted from MainLayout so the layout itself can stay
|
||||
statically rendered (layouts can't take RenderFragment Body across an interactive
|
||||
boundary). Hosts the collapsible NavSection groups and cookie persistence. *@
|
||||
|
||||
<nav class="side-rail">
|
||||
<div class="brand"><span class="mark">▮</span> OtOpcUa</div>
|
||||
|
||||
<NavSection Title="Navigation"
|
||||
Expanded="@_expanded.Contains("nav")"
|
||||
OnToggle="@(() => ToggleAsync("nav"))">
|
||||
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
|
||||
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
|
||||
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
|
||||
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
|
||||
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
|
||||
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
|
||||
</NavSection>
|
||||
|
||||
<NavSection Title="Scripting"
|
||||
Expanded="@_expanded.Contains("scripting")"
|
||||
OnToggle="@(() => ToggleAsync("scripting"))">
|
||||
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
|
||||
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
|
||||
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
|
||||
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
|
||||
</NavSection>
|
||||
|
||||
<NavSection Title="Live"
|
||||
Expanded="@_expanded.Contains("live")"
|
||||
OnToggle="@(() => ToggleAsync("live"))">
|
||||
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
|
||||
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
|
||||
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
|
||||
</NavSection>
|
||||
|
||||
<div class="rail-foot">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
|
||||
<div class="rail-roles">
|
||||
@string.Join(", ", context.User.Claims
|
||||
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||
</div>
|
||||
<form method="post" action="/auth/logout">
|
||||
<AntiforgeryToken />
|
||||
<button class="rail-btn" type="submit">Sign out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="rail-eyebrow">Session</div>
|
||||
<a class="rail-btn" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
// Expanded-section state persists in the `otopcua_nav` cookie via
|
||||
// wwwroot/js/nav-state.js (window.navState.get/.set). Same pattern as
|
||||
// ScadaLink CentralUI's NavMenu.
|
||||
|
||||
private static readonly string[] SectionIds = { "nav", "scripting", "live" };
|
||||
|
||||
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.LocationChanged += OnLocationChanged;
|
||||
// Seed from the URL so the current page's section is expanded on the
|
||||
// initial render — works even before JS interop is ready.
|
||||
EnsureCurrentSectionExpanded();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
|
||||
string saved;
|
||||
try
|
||||
{
|
||||
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
||||
}
|
||||
catch (JSDisconnectedException) { return; }
|
||||
catch (InvalidOperationException) { return; }
|
||||
|
||||
foreach (var id in saved.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (Array.IndexOf(SectionIds, id) >= 0)
|
||||
_expanded.Add(id);
|
||||
}
|
||||
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
await PersistAsync();
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
if (EnsureCurrentSectionExpanded())
|
||||
{
|
||||
_ = PersistAsync();
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAsync(string id)
|
||||
{
|
||||
if (!_expanded.Remove(id))
|
||||
_expanded.Add(id);
|
||||
await PersistAsync();
|
||||
}
|
||||
|
||||
private bool EnsureCurrentSectionExpanded()
|
||||
{
|
||||
var section = CurrentSection();
|
||||
return section is not null && _expanded.Add(section);
|
||||
}
|
||||
|
||||
private string? CurrentSection()
|
||||
{
|
||||
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||
var firstSegment = relative.Split('?', '#')[0]
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault();
|
||||
|
||||
return firstSegment switch
|
||||
{
|
||||
null or "" => "nav",
|
||||
"fleet" or "hosts" or "clusters" or "reservations" or "certificates" or "role-grants" => "nav",
|
||||
"virtual-tags" or "scripted-alarms" or "scripts" or "script-log" => "scripting",
|
||||
"deployments" or "alerts" or "alarms-historian" => "live",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PersistAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
||||
}
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (InvalidOperationException) { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ else
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="grp">LDAP group</label>
|
||||
<InputText id="grp" @bind-Value="_form.LdapGroup" class="form-control form-control-sm mono"
|
||||
placeholder="cn=Operators,ou=FleetAdmin,dc=lmxopcua,dc=local" />
|
||||
placeholder="cn=Operators,ou=FleetAdmin,dc=zb,dc=local" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Queries
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -74,10 +75,8 @@ else
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_rows = await db.ConfigAuditLogs.AsNoTracking()
|
||||
.Where(a => a.ClusterId == ClusterId)
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Take(PageSize)
|
||||
.ToListAsync();
|
||||
// Shared query: matches both the SP path (stamps ClusterId) and the structured
|
||||
// AuditWriterActor path (stamps NodeId, ClusterId null) so the latter's rows are visible.
|
||||
_rows = await ClusterAuditQuery.ForClusterAsync(db, ClusterId, PageSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations
|
||||
|
||||
@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")]
|
||||
@attribute [Authorize(Roles = "Administrator,Designer")]
|
||||
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject IAdminOperationsClient AdminOps
|
||||
|
||||
@@ -5,39 +5,14 @@
|
||||
the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response.
|
||||
Calling SignInAsync from an interactive circuit would be too late.
|
||||
|
||||
Uses LoginLayout (no side rail) so the page renders as a clean centred card. *@
|
||||
Uses LoginLayout (no side rail) so the page renders as a clean centred card.
|
||||
The card itself is the shared kit's <LoginCard> — it owns the .login-wrap centring
|
||||
wrapper, the .panel shell, and the static form-POST (username/password/returnUrl). *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
|
||||
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||
<section class="panel">
|
||||
<div style="padding:1.4rem 1.1rem 1.25rem">
|
||||
<h1 class="login-title">OtOpcUa Admin — sign in</h1>
|
||||
<form method="post" action="/auth/login" data-enhance="false">
|
||||
@if (ReturnUrl is not null)
|
||||
{
|
||||
<input type="hidden" name="returnUrl" value="@ReturnUrl"/>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input id="username" name="username" type="text"
|
||||
class="form-control form-control-sm" autocomplete="username"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input id="password" name="password" type="password"
|
||||
class="form-control form-control-sm" autocomplete="current-password"/>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Error))
|
||||
{
|
||||
<div class="panel notice" style="margin-bottom:.85rem">@Error</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<LoginCard Product="OtOpcUa Admin" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||
<AntiforgeryToken />
|
||||
</LoginCard>
|
||||
|
||||
@code {
|
||||
/// <summary>Error message surfaced by /auth/login after a failed bind.</summary>
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<div class="panel-head">LDAP binding</div>
|
||||
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
|
||||
<div class="kv"><span class="k">Server</span><span class="v mono">@_options.Server:@_options.Port</span></div>
|
||||
<div class="kv"><span class="k">UseTls</span><span class="v">@_options.UseTls</span></div>
|
||||
<div class="kv"><span class="k">Transport</span><span class="v">@_options.Transport</span></div>
|
||||
<div class="kv"><span class="k">SearchBase</span><span class="v mono small">@_options.SearchBase</span></div>
|
||||
@if (!_options.UseTls && _options.AllowInsecureLdap)
|
||||
@if (_options.Transport == ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport.None && _options.AllowInsecure)
|
||||
{
|
||||
<div class="kv"><span class="k">Warning</span><span class="v"><span class="chip chip-alert">Plaintext credentials over LDAP — dev mode only</span></span></div>
|
||||
}
|
||||
@@ -108,7 +108,7 @@
|
||||
private LdapOptions? _options;
|
||||
private IReadOnlyList<LdapGroupRoleMapping> _rows = [];
|
||||
private string _newGroup = "";
|
||||
private AdminRole _newRole = AdminRole.ConfigViewer;
|
||||
private AdminRole _newRole = AdminRole.Viewer;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null,
|
||||
}, default);
|
||||
_newGroup = "";
|
||||
_newRole = AdminRole.ConfigViewer;
|
||||
_newRole = AdminRole.Viewer;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
@* Status chip — wraps the theme.css .chip / .chip-ok / .chip-warn / .chip-bad / .chip-idle classes. *@
|
||||
<span class="chip @CssClass">@Text</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Text { get; set; } = "";
|
||||
[Parameter] public string CssClass { get; set; } = "chip-idle";
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Theme"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout
|
||||
@using ZB.MOM.WW.Theme
|
||||
|
||||
@@ -172,3 +172,13 @@
|
||||
max-width: 380px;
|
||||
margin: 3.5rem auto 0;
|
||||
}
|
||||
|
||||
/* --- App-specific rules not provided by ZB.MOM.WW.Theme (migrated during theme adoption) --- */
|
||||
/* OtOpcUa domain pages (Alerts, ScriptLog, Fleet, Hosts, AlarmsHistorian,
|
||||
RoleGrants, ImportEquipment) use two extra status-chip variants on top of the
|
||||
kit's .chip base + .chip-ok/.chip-warn/.chip-bad/.chip-idle/.chip-info set.
|
||||
.chip-alert is the red/danger variant (mirrors the kit's .chip-bad);
|
||||
.chip-caution is the amber variant (mirrors the kit's .chip-warn). Both reuse
|
||||
the kit's status tokens so they stay on-palette. */
|
||||
.chip-alert { color: var(--bad); background: var(--bad-bg); border-color: var(--bad-border); }
|
||||
.chip-caution { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
/* ============================================================================
|
||||
Technical-Light design system — portable theme layer
|
||||
----------------------------------------------------------------------------
|
||||
A refined technical-light aesthetic: warm-neutral paper, hairline rules,
|
||||
IBM Plex type, monospace tabular numerics, status carried by colour. Built
|
||||
to layer over Bootstrap 5 via --bs-* overrides, but every rule below works
|
||||
standalone — Bootstrap is optional.
|
||||
|
||||
HOW TO ADOPT
|
||||
1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the
|
||||
@font-face url() paths below to wherever you serve them.
|
||||
2. Include this file once, globally. Add view-specific rules in a separate
|
||||
stylesheet — never edit the token block per-view.
|
||||
3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.*
|
||||
helpers; do not hand-pick hex values in feature CSS.
|
||||
========================================================================= */
|
||||
|
||||
/* ── Vendored fonts (embedded woff2, no network/CDN fetch) ───────────────────
|
||||
Adjust these url()s to your asset route. If you cannot vendor the fonts the
|
||||
--sans / --mono fallback stacks below degrade gracefully to system fonts. */
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal; font-weight: 400; font-display: swap;
|
||||
src: url('fonts/ibm-plex-sans-400.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
font-style: normal; font-weight: 600; font-display: swap;
|
||||
src: url('fonts/ibm-plex-sans-600.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal; font-weight: 500; font-display: swap;
|
||||
src: url('fonts/ibm-plex-mono-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ── Design tokens ───────────────────────────────────────────────────────────
|
||||
The single source of truth. Re-theme by editing only this block. */
|
||||
:root {
|
||||
/* Surfaces & ink */
|
||||
--paper: #f4f4f1; /* page background — warm off-white, never pure */
|
||||
--card: #ffffff; /* raised surfaces: cards, bars, table heads */
|
||||
--ink: #1b1d21; /* primary text */
|
||||
--ink-soft: #5a6066; /* secondary text, labels */
|
||||
--ink-faint: #8b9097; /* tertiary text, captions, units */
|
||||
--rule: #e4e4df; /* hairline borders / row dividers */
|
||||
--rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */
|
||||
|
||||
/* Accent */
|
||||
--accent: #2f5fd0; /* links, sort arrows, primary actions */
|
||||
--accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */
|
||||
|
||||
/* Status — foreground */
|
||||
--ok: #2f9e44;
|
||||
--warn: #e8920c;
|
||||
--bad: #e03131;
|
||||
--idle: #868e96;
|
||||
|
||||
/* Status — tinted backgrounds (pair with the matching foreground) */
|
||||
--ok-bg: #e9f6ec;
|
||||
--warn-bg: #fdf1dd;
|
||||
--bad-bg: #fceaea;
|
||||
--idle-bg: #eef0f2;
|
||||
|
||||
/* Type stacks — Plex first, graceful system fallback */
|
||||
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
|
||||
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
|
||||
/* Bootstrap 5 overrides — harmless if Bootstrap is absent */
|
||||
--bs-body-bg: var(--paper);
|
||||
--bs-body-color: var(--ink);
|
||||
--bs-body-font-family: var(--sans);
|
||||
--bs-body-font-size: 0.9rem;
|
||||
--bs-primary: var(--accent);
|
||||
--bs-border-color: var(--rule);
|
||||
--bs-emphasis-color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── Base ────────────────────────────────────────────────────────────────────
|
||||
The faint top-right radial is the one deliberate flourish — a soft sheen,
|
||||
not a gradient wash. Keep it subtle. */
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
|
||||
var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.9rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */
|
||||
.numeric,
|
||||
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
|
||||
/* ── App chrome: top bar ─────────────────────────────────────────────────────
|
||||
One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta
|
||||
text and any status pill pushed hard right. */
|
||||
.app-bar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
}
|
||||
.app-bar .brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */
|
||||
.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; }
|
||||
.app-bar .spacer { flex: 1; } /* pushes meta/pill right */
|
||||
.app-bar .meta {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* ── Connection / liveness pill ──────────────────────────────────────────────
|
||||
A rounded pill with a dot, driven entirely by data-state. Use for any
|
||||
live-link health indicator (websocket, SSE, polling). */
|
||||
.conn-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--rule-strong);
|
||||
color: var(--ink-soft);
|
||||
background: var(--card);
|
||||
}
|
||||
.conn-pill .dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--idle);
|
||||
}
|
||||
.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
|
||||
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
|
||||
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
|
||||
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
|
||||
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
|
||||
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
|
||||
/* ── Status text helpers ─────────────────────────────────────────────────────
|
||||
Recolour a value in place — counts, ratios, error totals. */
|
||||
.s-ok { color: var(--ok); }
|
||||
.s-warn { color: var(--warn); }
|
||||
.s-bad { color: var(--bad); }
|
||||
.s-idle { color: var(--idle); }
|
||||
|
||||
/* ── State chip ──────────────────────────────────────────────────────────────
|
||||
Compact rectangular badge for an enumerated state (bound/recovering/…).
|
||||
Squarer than the pill; use the pill for liveness, the chip for state. */
|
||||
.chip {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
|
||||
.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
|
||||
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
|
||||
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
|
||||
|
||||
/* ── Panel — the base raised surface ─────────────────────────────────────────
|
||||
A white card with a hairline border and 8px radius. .panel-head is the
|
||||
uppercase eyebrow label that sits on top. */
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.panel-head {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--ink-faint);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* ── Page wrapper ────────────────────────────────────────────────────────────
|
||||
Centred, capped width, even gutter. */
|
||||
.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; }
|
||||
|
||||
/* ── Reveal-on-paint ─────────────────────────────────────────────────────────
|
||||
Add .rise to top-level sections; stagger with inline animation-delay
|
||||
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.rise { animation: rise 0.4s ease both; }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
COMPONENT LIBRARY
|
||||
Generic, reusable pieces. View-specific layout belongs in a separate sheet.
|
||||
════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── KPI / aggregate cards ───────────────────────────────────────────────────
|
||||
A responsive strip of headline numbers. .agg-card.alert / .caution tint the
|
||||
whole card when a watched metric goes non-zero. */
|
||||
.agg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.agg-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
}
|
||||
.agg-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.agg-value {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
|
||||
.agg-card.alert .agg-value { color: var(--bad); }
|
||||
.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
|
||||
.agg-card.caution .agg-value { color: #b56a00; }
|
||||
|
||||
/* ── Metric card + key/value rows ────────────────────────────────────────────
|
||||
A .panel-head over a stack of .kv rows: label left, monospace value right.
|
||||
Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.metric-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric-card .panel-head { margin: 0; }
|
||||
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
padding: 0.32rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.kv:nth-child(even) { background: #fbfbf9; }
|
||||
.kv .k { color: var(--ink-soft); }
|
||||
.kv .v {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.kv .v.warn { color: var(--warn); }
|
||||
.kv .v.bad { color: var(--bad); }
|
||||
.kv .v.ok { color: var(--ok); }
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────────────────
|
||||
Filter/search row that sits inside a .panel above a table. */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.toolbar .spacer { flex: 1; }
|
||||
.tb-search { max-width: 280px; }
|
||||
.tb-state { max-width: 150px; }
|
||||
.tb-check {
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); }
|
||||
|
||||
/* ── Data table ──────────────────────────────────────────────────────────────
|
||||
Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric
|
||||
columns get .num (right-aligned, monospace). Rows are clickable by default —
|
||||
drop the cursor/hover rules if yours are not. */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.45rem 0.8rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.data-table th {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-faint);
|
||||
background: #fbfbf9;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.data-table th.num,
|
||||
.data-table td.num { text-align: right; font-family: var(--mono); }
|
||||
|
||||
.data-table th.sortable { cursor: pointer; user-select: none; }
|
||||
.data-table th.sortable:hover { color: var(--ink); }
|
||||
.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); }
|
||||
.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
|
||||
|
||||
.data-table tbody tr { cursor: pointer; transition: background 0.08s; }
|
||||
.data-table tbody tr:hover { background: #f3f6fd; }
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
.empty-row {
|
||||
text-align: center !important;
|
||||
color: var(--ink-faint);
|
||||
padding: 1.6rem !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Direction / category tag ────────────────────────────────────────────────
|
||||
Tiny inline tag for a per-row category (e.g. read vs write). */
|
||||
.dir-tag {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
|
||||
.dir-write { color: #8a5a00; background: var(--warn-bg); }
|
||||
|
||||
/* ── Inline notice ───────────────────────────────────────────────────────────
|
||||
A .panel with a warning tint — for "this thing is gone / degraded" banners. */
|
||||
.notice {
|
||||
padding: 0.85rem 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #b56a00;
|
||||
background: var(--warn-bg);
|
||||
border-color: #efd6a6;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,19 +0,0 @@
|
||||
// Sidebar nav collapse state — persisted in the `otopcua_nav` cookie so it
|
||||
// survives full page reloads and reconnects. Invoked from MainLayout.razor via
|
||||
// JS interop (window.navState.get / .set). Mirrors the ScadaLink pattern at
|
||||
// /Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js.
|
||||
window.navState = {
|
||||
// Returns the raw cookie value (comma-separated expanded section ids), or
|
||||
// an empty string when the cookie is absent.
|
||||
get: function () {
|
||||
const match = document.cookie.match(/(?:^|;\s*)otopcua_nav=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
},
|
||||
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
|
||||
// (JS must write it) and not sensitive.
|
||||
set: function (value) {
|
||||
const oneYearSeconds = 60 * 60 * 24 * 365;
|
||||
document.cookie = "otopcua_nav=" + encodeURIComponent(value) +
|
||||
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Maps OtOpcUa's audit <c>Action</c> vocabulary onto the canonical
|
||||
/// <see cref="AuditOutcome"/>. The vocabulary is the set of values documented on
|
||||
/// <c>ConfigAuditLog.EventType</c>: config verbs are <see cref="AuditOutcome.Success"/>,
|
||||
/// the two authorization-rejection events are <see cref="AuditOutcome.Denied"/>. OtOpcUa
|
||||
/// emits no <see cref="AuditOutcome.Failure"/> events today.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pure function — no live emit sites construct an <see cref="AuditEvent"/> in production
|
||||
/// (the structured audit path is dormant; all live audit flows through the bespoke stored
|
||||
/// procedure path). This helper exists so that when the structured path is wired up, the
|
||||
/// required <c>Outcome</c> field is derived consistently from the action verb. Tested, not
|
||||
/// yet exercised in production.
|
||||
/// </remarks>
|
||||
public static class AuditOutcomeMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Derives the canonical <see cref="AuditOutcome"/> for an OtOpcUa audit action verb.
|
||||
/// Unknown verbs default to <see cref="AuditOutcome.Success"/> (config writes are the
|
||||
/// overwhelming majority and the only non-success cases are the two explicit
|
||||
/// authorization rejections enumerated below).
|
||||
/// </summary>
|
||||
/// <param name="action">The audit action verb (e.g. <c>DraftCreated</c>, <c>OpcUaAccessDenied</c>).</param>
|
||||
/// <returns>The mapped outcome.</returns>
|
||||
public static AuditOutcome FromAction(string action) => action switch
|
||||
{
|
||||
"OpcUaAccessDenied" or "CrossClusterNamespaceAttempt" => AuditOutcome.Denied,
|
||||
"DraftCreated"
|
||||
or "DraftEdited"
|
||||
or "Published"
|
||||
or "RolledBack"
|
||||
or "NodeApplied"
|
||||
or "ClusterCreated"
|
||||
or "NodeAdded"
|
||||
or "CredentialAdded"
|
||||
or "CredentialDisabled"
|
||||
or "ExternalIdReleased" => AuditOutcome.Success,
|
||||
_ => AuditOutcome.Success,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
@@ -19,8 +19,13 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
|
||||
/// <c>UX_ConfigAuditLog_EventId</c> (cross-restart safety — a retry of an already-flushed
|
||||
/// batch hits the constraint and we drop the duplicate insert without losing the rest of
|
||||
/// the batch).
|
||||
///
|
||||
/// Implements the shared <see cref="IAuditWriter"/> seam: <see cref="WriteAsync"/> is a
|
||||
/// best-effort, never-throwing entry point that simply <c>Tell</c>s this actor and returns
|
||||
/// a completed task, so non-Akka callers can emit canonical audit events through the same
|
||||
/// batching/dedup pipeline as in-cluster <c>Tell</c> traffic.
|
||||
/// </summary>
|
||||
public sealed class AuditWriterActor : ReceiveActor, IWithTimers
|
||||
public sealed class AuditWriterActor : ReceiveActor, IWithTimers, IAuditWriter
|
||||
{
|
||||
public const int FlushBatchSize = 500;
|
||||
public static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(5);
|
||||
@@ -52,6 +57,23 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
|
||||
Timers.StartPeriodicTimer("flush", Flush.Instance, FlushInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAuditWriter"/> seam. Best-effort and never throws: routes the event onto this
|
||||
/// actor's mailbox via <c>Tell</c> (thread-safe from any caller) so it flows through the same
|
||||
/// batching + dedup pipeline as in-cluster traffic, then returns immediately. The actual
|
||||
/// persistence happens asynchronously on the next flush; a write failure there is logged and
|
||||
/// the batch dropped (per the best-effort audit contract).
|
||||
/// </summary>
|
||||
/// <param name="evt">The canonical audit event to persist.</param>
|
||||
/// <param name="ct">Unused — enqueue is synchronous and non-blocking.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
// Akka Tell is safe to call from any thread and never throws to the caller.
|
||||
Self.Tell(evt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void HandleEvent(AuditEvent evt)
|
||||
{
|
||||
// In-buffer dedup. Last write wins on duplicate EventId within the batch — events
|
||||
@@ -74,13 +96,14 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
db.ConfigAuditLogs.Add(new ConfigAuditLog
|
||||
{
|
||||
Timestamp = evt.OccurredAtUtc,
|
||||
Timestamp = evt.OccurredAtUtc.UtcDateTime,
|
||||
Principal = evt.Actor,
|
||||
EventType = $"{evt.Category}:{evt.Action}",
|
||||
NodeId = evt.SourceNode.Value,
|
||||
NodeId = evt.SourceNode,
|
||||
DetailsJson = evt.DetailsJson,
|
||||
EventId = evt.EventId,
|
||||
CorrelationId = evt.CorrelationId.Value,
|
||||
CorrelationId = evt.CorrelationId,
|
||||
Outcome = evt.Outcome.ToString(),
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Akka.Cluster.Hosting"/>
|
||||
<PackageReference Include="Akka.Cluster.Tools"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fail-fast startup validator for <see cref="LdapOptions"/>, built on the shared
|
||||
/// <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>. When LDAP login
|
||||
/// is enabled, <c>Server</c> and <c>SearchBase</c> must be set and <c>Port</c> must be a valid
|
||||
/// TCP port; when disabled — or when <c>DevStubMode</c> bypasses the real bind — all checks are
|
||||
/// skipped. <c>ServiceAccountDn</c>/<c>Password</c> are
|
||||
/// intentionally not required — an empty pair selects the direct-bind path (see
|
||||
/// <see cref="LdapOptions.ServiceAccountDn"/>). Failure messages use <c>"Ldap:"</c> as a
|
||||
/// human-readable field prefix — not the literal bound section path, which is
|
||||
/// <c>Security:Ldap</c> (see <see cref="LdapOptions.SectionName"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Insecure-transport guard (review fix): a real-LDAP config that selects plaintext transport
|
||||
/// (<see cref="LdapTransport.None"/>) without opting in via <see cref="LdapOptions.AllowInsecure"/>
|
||||
/// now FAILS startup validation, so an insecure-by-accident production overlay never boots.
|
||||
/// This mirrors the login-time fail-closed guard in <see cref="OtOpcUaLdapAuthService"/> and is
|
||||
/// gated on the same conditions (<see cref="LdapOptions.Enabled"/> AND not
|
||||
/// <see cref="LdapOptions.DevStubMode"/>): a disabled or dev-stub config is exempt, exactly as it
|
||||
/// is exempt from the real bind. The login-time guard remains as defence in depth.
|
||||
/// </remarks>
|
||||
public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, LdapOptions options)
|
||||
{
|
||||
// Skip the real-LDAP field checks when LDAP login is disabled, or when the dev stub is
|
||||
// active — DevStubMode bypasses the real bind entirely, so Server/SearchBase/Port are
|
||||
// irrelevant and would otherwise force dev configs to carry meaningless placeholders.
|
||||
if (!options.Enabled || options.DevStubMode) return;
|
||||
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(options.Server),
|
||||
"Ldap:Server is required when LDAP login is enabled.");
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(options.SearchBase),
|
||||
"Ldap:SearchBase is required when LDAP login is enabled.");
|
||||
builder.Port(options.Port, "Ldap:Port");
|
||||
|
||||
// Fail closed at startup on a plaintext transport unless explicitly opted in — same
|
||||
// condition the login-time guard in OtOpcUaLdapAuthService enforces, lifted to boot so an
|
||||
// insecure-by-accident production overlay refuses to start rather than silently failing
|
||||
// every bind at login.
|
||||
builder.RequireThat(
|
||||
!(options.Transport == LdapTransport.None && !options.AllowInsecure),
|
||||
"LDAP transport is None (plaintext) but AllowInsecure is false — set Transport to Ldaps/StartTls or set AllowInsecure for dev.");
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fail-fast startup validator for <see cref="OpcUaApplicationHostOptions"/>, built on the
|
||||
/// shared <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>. The C#
|
||||
/// defaults are all valid, so a host with no explicit <c>"OpcUa"</c> section passes untouched;
|
||||
/// the validator exists to reject explicit prod/env overrides before the OPC UA SDK boots.
|
||||
/// Identity/transport essentials (<c>ApplicationName</c>, <c>ApplicationUri</c>,
|
||||
/// <c>PublicHostname</c>, <c>PkiStoreRoot</c>, <c>OpcUaPort</c>) must be present/valid and at
|
||||
/// least one security profile must be enabled. Optional fields — <c>ApplicationConfigPath</c>,
|
||||
/// <c>PeerApplicationUris</c>, <c>AutoAcceptUntrustedClientCertificates</c>, and
|
||||
/// <c>ProductUri</c> — are intentionally not validated. Failure messages carry the real
|
||||
/// <c>"OpcUa:"</c> section prefix matching the bound configuration section.
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<OpcUaApplicationHostOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, OpcUaApplicationHostOptions o)
|
||||
{
|
||||
builder.Required(o.ApplicationName, "OpcUa:ApplicationName");
|
||||
builder.Required(o.ApplicationUri, "OpcUa:ApplicationUri");
|
||||
builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
|
||||
builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
|
||||
builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
|
||||
// EnabledSecurityProfiles is declared as IList<T> — that interface does not derive from
|
||||
// IReadOnlyCollection<T>, so it can't bind to MinCount's IReadOnlyCollection<T> parameter
|
||||
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
|
||||
builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Reports Healthy on the admin-role leader, Degraded on a non-leader admin member. Used by
|
||||
/// the <c>/health/active</c> endpoint so external load balancers can route admin-singleton
|
||||
/// traffic to the current leader (cookie sessions still work on either node — DataProtection
|
||||
/// keys are shared).
|
||||
/// </summary>
|
||||
public sealed class AdminRoleLeaderHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IClusterRoleInfo _roleInfo;
|
||||
|
||||
/// <summary>Initializes a new instance of the AdminRoleLeaderHealthCheck class.</summary>
|
||||
/// <param name="roleInfo">The cluster role information provider.</param>
|
||||
public AdminRoleLeaderHealthCheck(IClusterRoleInfo roleInfo)
|
||||
{
|
||||
_roleInfo = roleInfo;
|
||||
}
|
||||
|
||||
/// <summary>Checks the health status of the admin role leader.</summary>
|
||||
/// <param name="context">The health check context.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the health check operation.</returns>
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_roleInfo.HasRole("admin"))
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Node does not carry admin role"));
|
||||
|
||||
var leader = _roleInfo.RoleLeader("admin");
|
||||
var isLeader = leader is not null && leader.Value.Equals(_roleInfo.LocalNode);
|
||||
|
||||
return Task.FromResult(isLeader
|
||||
? HealthCheckResult.Healthy($"Admin leader ({_roleInfo.LocalNode})")
|
||||
: HealthCheckResult.Degraded($"Admin member but not leader (leader={leader?.Value ?? "<unknown>"})"));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ActorSystem _system;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AkkaClusterHealthCheck class.
|
||||
/// </summary>
|
||||
/// <param name="system">The Akka actor system to check cluster health for.</param>
|
||||
public AkkaClusterHealthCheck(ActorSystem system)
|
||||
{
|
||||
_system = system;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of the Akka cluster asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="context">The health check context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cluster = Akka.Cluster.Cluster.Get(_system);
|
||||
var selfUp = cluster.State.Members.Any(m =>
|
||||
m.Address == cluster.SelfAddress && m.Status == MemberStatus.Up);
|
||||
|
||||
return Task.FromResult(selfUp
|
||||
? HealthCheckResult.Healthy($"Self Up; {cluster.State.Members.Count} member(s)")
|
||||
: HealthCheckResult.Degraded("Self not yet Up in cluster"));
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
public sealed class DatabaseHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseHealthCheck"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbFactory">The database context factory for the config database.</param>
|
||||
public DatabaseHealthCheck(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of the configuration database.
|
||||
/// </summary>
|
||||
/// <param name="context">The health check context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
await db.Deployments.AsNoTracking().Take(1).ToListAsync(cancellationToken);
|
||||
return HealthCheckResult.Healthy("ConfigDb reachable");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("ConfigDb unreachable", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,40 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.Health;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.Health.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
public static class HealthEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the standard ASP.NET Core health-check infrastructure plus the OtOpcUa-specific
|
||||
/// probes. Mirrors ScadaLink's three-tier pattern: <c>ready</c> = boot ok; <c>active</c> =
|
||||
/// fully serving traffic; <c>healthz</c> = bare process liveness.
|
||||
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
|
||||
/// ready+active; admin-leader on active only.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register health checks with.</param>
|
||||
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<DatabaseHealthCheck>("configdb", tags: new[] { "ready", "active" })
|
||||
.AddCheck<AkkaClusterHealthCheck>("akka", tags: new[] { "ready", "active" })
|
||||
.AddCheck<AdminRoleLeaderHealthCheck>("admin-leader", tags: new[] { "active" });
|
||||
.AddTypeActivatedCheck<DatabaseHealthCheck<OtOpcUaConfigDbContext>>(
|
||||
"configdb",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
||||
args: new DatabaseHealthCheckOptions<OtOpcUaConfigDbContext>
|
||||
{
|
||||
ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct),
|
||||
})
|
||||
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
|
||||
"akka",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
||||
args: AkkaClusterStatusPolicy.OtOpcUaCompat)
|
||||
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
|
||||
"admin-leader",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Active },
|
||||
args: "admin");
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -27,21 +42,7 @@ public static class HealthEndpoints
|
||||
/// <param name="app">The endpoint route builder.</param>
|
||||
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// AllowAnonymous on all three — Traefik / k8s liveness probes / load-balancers
|
||||
// hit these without credentials. Without it the AddOtOpcUaAuth fallback policy
|
||||
// 401s every probe and Traefik marks every backend unhealthy.
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("ready"),
|
||||
}).AllowAnonymous();
|
||||
app.MapHealthChecks("/health/active", new HealthCheckOptions
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("active"),
|
||||
}).AllowAnonymous();
|
||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => false, // process-liveness only — no probes run.
|
||||
}).AllowAnonymous();
|
||||
app.MapZbHealth();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.Telemetry;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
|
||||
@@ -15,16 +15,25 @@ public static class ObservabilityExtensions
|
||||
{
|
||||
/// <summary>Adds OtOpcUa observability (metrics and tracing) to the service collection.</summary>
|
||||
/// <param name="services">The service collection to add observability services to.</param>
|
||||
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services)
|
||||
/// <param name="configuration">
|
||||
/// Configuration read for the opt-in OTLP exporter. <c>OtOpcUa:Telemetry:Exporter</c>
|
||||
/// (parsed case-insensitively to <see cref="ZbExporter"/>) switches to OTLP when set to
|
||||
/// <c>Otlp</c>; <c>OtOpcUa:Telemetry:OtlpEndpoint</c> sets the OTLP endpoint. With no
|
||||
/// config the exporter stays Prometheus (the default).
|
||||
/// </param>
|
||||
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOpenTelemetry()
|
||||
.WithMetrics(b => b
|
||||
.AddMeter(OtOpcUaTelemetry.MeterName)
|
||||
.AddPrometheusExporter())
|
||||
.WithTracing(b => b
|
||||
.AddSource(OtOpcUaTelemetry.ActivitySourceName));
|
||||
|
||||
return services;
|
||||
return services.AddZbTelemetry(o =>
|
||||
{
|
||||
o.ServiceName = "otopcua";
|
||||
o.Meters = [OtOpcUaTelemetry.MeterName];
|
||||
o.ActivitySources = [OtOpcUaTelemetry.ActivitySourceName];
|
||||
if (Enum.TryParse<ZbExporter>(configuration["OtOpcUa:Telemetry:Exporter"], ignoreCase: true, out var exporter))
|
||||
o.Exporter = exporter;
|
||||
var otlp = configuration["OtOpcUa:Telemetry:OtlpEndpoint"];
|
||||
if (!string.IsNullOrWhiteSpace(otlp))
|
||||
o.OtlpEndpoint = otlp;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -35,7 +44,7 @@ public static class ObservabilityExtensions
|
||||
/// <param name="app">The endpoint route builder.</param>
|
||||
public static IEndpointRouteBuilder MapOtOpcUaMetrics(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPrometheusScrapingEndpoint("/metrics");
|
||||
app.MapZbMetrics();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
@@ -8,15 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
|
||||
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
|
||||
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
|
||||
/// session identities. Roles flow through unchanged — the data-plane ACL evaluator reads
|
||||
/// them off <c>OperationContext.UserIdentity</c> downstream.
|
||||
/// session identities. Roles are resolved through the shared
|
||||
/// <see cref="IGroupRoleMapper{TRole}"/> seam from the LDAP groups returned by the directory —
|
||||
/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA
|
||||
/// session identity for the downstream data-plane ACL evaluator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This authenticator is registered as a singleton, but <see cref="IGroupRoleMapper{TRole}"/>
|
||||
/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to
|
||||
/// resolve the mapper so the singleton never captures a scoped dependency.
|
||||
/// </remarks>
|
||||
public sealed class LdapOpcUaUserAuthenticator(
|
||||
ILdapAuthService ldap,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<LdapOpcUaUserAuthenticator> logger)
|
||||
: IOpcUaUserAuthenticator
|
||||
{
|
||||
/// <summary>Authenticates an OPC UA UserName token via LDAP.</summary>
|
||||
/// <summary>Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper.</summary>
|
||||
/// <param name="username">The username to authenticate.</param>
|
||||
/// <param name="password">The password to authenticate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
@@ -29,7 +39,9 @@ public sealed class LdapOpcUaUserAuthenticator(
|
||||
{
|
||||
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
|
||||
}
|
||||
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
|
||||
|
||||
var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false);
|
||||
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -37,4 +49,36 @@ public sealed class LdapOpcUaUserAuthenticator(
|
||||
return OpcUaUserAuthResult.Deny("Authentication backend error");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the user's roles from their LDAP groups via the scoped
|
||||
/// <see cref="IGroupRoleMapper{TRole}"/>, unioned with any pre-resolved roles (the DevStub
|
||||
/// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated
|
||||
/// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour.
|
||||
/// </summary>
|
||||
/// <param name="groups">The LDAP groups returned by the directory.</param>
|
||||
/// <param name="preResolved">Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).</param>
|
||||
/// <param name="username">The login name, for diagnostics.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
private async Task<IReadOnlyList<string>> ResolveRolesAsync(
|
||||
IReadOnlyList<string> groups, IReadOnlyList<string> preResolved, string username, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
|
||||
|
||||
var roles = new HashSet<string>(preResolved, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var role in mapping.Roles)
|
||||
roles.Add(role);
|
||||
return [.. roles];
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username);
|
||||
return preResolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly OpcUaApplicationHostOptions _options;
|
||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
@@ -33,19 +33,19 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the OtOpcUaServerHostedService class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <param name="options">The validated OPC UA host options (bound from the <c>OpcUa</c> section and validated at startup via <c>ValidateOnStart</c>).</param>
|
||||
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
|
||||
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
|
||||
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
||||
public OtOpcUaServerHostedService(
|
||||
IConfiguration configuration,
|
||||
IOptions<OpcUaApplicationHostOptions> options,
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
DeferredServiceLevelPublisher deferredServiceLevel,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_options = options.Value;
|
||||
_deferredSink = deferredSink;
|
||||
_deferredServiceLevel = deferredServiceLevel;
|
||||
_userAuthenticator = userAuthenticator;
|
||||
@@ -59,12 +59,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions();
|
||||
_configuration.GetSection("OpcUa").Bind(options);
|
||||
|
||||
_server = new OtOpcUaSdkServer();
|
||||
_appHost = new OpcUaApplicationHost(
|
||||
options,
|
||||
_options,
|
||||
_loggerFactory.CreateLogger<OpcUaApplicationHost>(),
|
||||
_userAuthenticator);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Akka.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
||||
@@ -10,16 +11,21 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -45,11 +51,10 @@ var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r =>
|
||||
if (roleSuffix is not null)
|
||||
builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true);
|
||||
|
||||
// Serilog — rolling daily file sink per CLAUDE.md. Console for local dev.
|
||||
builder.Host.UseSerilog((ctx, lc) => lc
|
||||
.ReadFrom.Configuration(ctx.Configuration)
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/otopcua-.log", rollingInterval: RollingInterval.Day));
|
||||
// Serilog — shared ZB.MOM.WW.Telemetry bootstrap. Sinks (Console + rolling daily file)
|
||||
// now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the
|
||||
// shared NodeHostname / TraceContext / Redaction enrichers and trace correlation.
|
||||
builder.AddZbSerilog(o => o.ServiceName = "otopcua");
|
||||
|
||||
// Windows-service registration is handled at install time by scripts/install/Install-Services.ps1
|
||||
// (Task 62) rather than in-process, so the binary stays cross-platform-compilable.
|
||||
@@ -96,10 +101,24 @@ if (hasDriver)
|
||||
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||
|
||||
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
||||
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers these) ends up
|
||||
// with exactly one descriptor; on a driver-only node these are the sole registrations.
|
||||
// OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the
|
||||
// shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper
|
||||
// <string> per call to turn the directory's groups into roles, so register it here for driver-
|
||||
// only nodes (AddOtOpcUaAuth registers it on admin nodes); ILdapGroupRoleMappingService it
|
||||
// depends on is already registered unconditionally by AddOtOpcUaConfigDb above.
|
||||
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||
|
||||
// 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
|
||||
// re-binding the section imperatively. Defaults pass; this guards explicit prod/env overrides.
|
||||
builder.Services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
|
||||
builder.Configuration, "OpcUa");
|
||||
|
||||
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
@@ -128,14 +147,14 @@ if (hasAdmin)
|
||||
// registered". Idempotent on fused admin,driver nodes (TryAddEnumerable de-dups).
|
||||
builder.Services.AddOtOpcUaDriverProbes();
|
||||
// Flow AuthenticationState through cascading parameters so <AuthorizeView/> works
|
||||
// inside interactive components (NavSidebar's session block).
|
||||
// in the static MainLayout footer and other components (e.g. Account.razor, Routes.razor).
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddOtOpcUaAdminClients();
|
||||
}
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Health" />
|
||||
<PackageReference Include="ZB.MOM.WW.Health.Akka" />
|
||||
<PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
"DevStubMode": false,
|
||||
"Transport": "Ldaps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
"DevStubMode": false,
|
||||
"Transport": "Ldaps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"Security": {
|
||||
"Ldap": {
|
||||
"DevStubMode": false
|
||||
"DevStubMode": false,
|
||||
"Transport": "Ldaps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
{}
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" },
|
||||
{ "Name": "File", "Args": { "path": "logs/otopcua-.log", "rollingInterval": "Day" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Default-resolution helpers for the <c>Actor</c> field of a canonical
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Usage pattern — call <see cref="Resolve"/> when constructing an <c>AuditEvent</c>:
|
||||
/// <code>
|
||||
/// new AuditEvent
|
||||
/// {
|
||||
/// Actor = AuditActor.Resolve(auditActorAccessor),
|
||||
/// ...
|
||||
/// }
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Note:</b> OtOpcUa has no live structured <c>AuditEvent</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AuditActor
|
||||
{
|
||||
/// <summary>The fallback actor string used when no authenticated principal is available.</summary>
|
||||
public const string SystemFallback = "system";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current principal's actor string from <paramref name="accessor"/>, or
|
||||
/// <see cref="SystemFallback"/> when the accessor returns <see langword="null"/>
|
||||
/// (no HTTP context, unauthenticated, or in a background/non-HTTP execution context).
|
||||
/// </summary>
|
||||
/// <param name="accessor">The audit-actor accessor. May be <see langword="null"/>
|
||||
/// (e.g. in a background context where DI did not wire the accessor).</param>
|
||||
/// <returns>The actor string — never <see langword="null"/>.</returns>
|
||||
public static string Resolve(IAuditActorAccessor? accessor) =>
|
||||
Resolve(accessor, SystemFallback);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current principal's actor string from <paramref name="accessor"/>, or
|
||||
/// <paramref name="fallback"/> when the accessor returns <see langword="null"/>.
|
||||
/// </summary>
|
||||
/// <param name="accessor">The audit-actor accessor. May be <see langword="null"/>.</param>
|
||||
/// <param name="fallback">The explicit fallback value.</param>
|
||||
/// <returns>The actor string — never <see langword="null"/>.</returns>
|
||||
public static string Resolve(IAuditActorAccessor? accessor, string fallback) =>
|
||||
accessor?.CurrentActor ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP-context–backed <see cref="IAuditActorAccessor"/> for the OtOpcUa control-plane.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads the authenticated principal from <see cref="IHttpContextAccessor"/>:
|
||||
/// <list type="number">
|
||||
/// <item>If there is no current <c>HttpContext</c> or the user is not authenticated,
|
||||
/// returns <see langword="null"/>.</item>
|
||||
/// <item>Otherwise, returns the <see cref="ZbClaimTypes.Username"/> claim value (the
|
||||
/// canonical directory login name set at sign-in by <c>AuthEndpoints</c>).</item>
|
||||
/// <item>Falls back to the <see cref="ZbClaimTypes.Name"/> claim, then to
|
||||
/// <see cref="System.Security.Principal.IIdentity.Name"/>, in that order.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Registered as <b>scoped</b> in <see cref="ZB.MOM.WW.OtOpcUa.Security.ServiceCollectionExtensions.AddOtOpcUaAuth"/>
|
||||
/// so that it correctly follows the request scope used by Blazor Server interactive components
|
||||
/// and minimal-API endpoints. <c>IHttpContextAccessor</c> is registered by
|
||||
/// <c>AddOtOpcUaAuth</c> via <c>services.AddHttpContextAccessor()</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HttpAuditActorAccessor : IAuditActorAccessor
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <summary>Initializes the accessor with the ASP.NET Core HTTP context accessor.</summary>
|
||||
/// <param name="httpContextAccessor">The HTTP context accessor.</param>
|
||||
public HttpAuditActorAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current HTTP principal's actor string for inclusion in a canonical
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> as the <c>Actor</c> field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The seam abstracts the identity source so that:
|
||||
/// <list type="bullet">
|
||||
/// <item>production code uses <see cref="HttpAuditActorAccessor"/> (reads the
|
||||
/// authenticated Blazor cookie principal from <c>IHttpContextAccessor</c>); and</item>
|
||||
/// <item>unit tests or non-HTTP contexts can substitute a stub or return
|
||||
/// <see langword="null"/> (which triggers the <c>"system"</c> fallback in
|
||||
/// <see cref="AuditActor.Resolve"/>).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Note:</b> OtOpcUa has no live structured <c>AuditEvent</c> 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
|
||||
/// <see cref="AuditActor.Resolve"/> and get the Auth principal automatically.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IAuditActorAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the authenticated principal's actor string, or <see langword="null"/> when
|
||||
/// there is no current HTTP context or the user is not authenticated.
|
||||
/// </summary>
|
||||
string? CurrentActor { get; }
|
||||
}
|
||||
@@ -8,7 +8,8 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
@@ -43,7 +44,7 @@ public static class AuthEndpoints
|
||||
private static async Task<IResult> LoginAsync(
|
||||
HttpContext http,
|
||||
ILdapAuthService ldap,
|
||||
ILdapGroupRoleMappingService roleMappings,
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var isForm = http.Request.HasFormContentType;
|
||||
@@ -87,28 +88,43 @@ public static class AuthEndpoints
|
||||
return Results.Redirect("/login" + qs);
|
||||
}
|
||||
|
||||
// Role resolution now lives behind the shared IGroupRoleMapper<string> seam
|
||||
// (OtOpcUaGroupRoleMapper): it applies the appsettings GroupToRole baseline AND merges
|
||||
// system-wide DB grants from the user's LDAP groups. result.Roles is empty on the real
|
||||
// LDAP path (the library returns groups, not roles); it is only pre-populated on the
|
||||
// DevStub success path (FleetAdmin) — union that pre-resolved set in so the dev grant
|
||||
// survives the move to the mapper.
|
||||
IReadOnlyList<string> roles = result.Roles;
|
||||
try
|
||||
{
|
||||
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
|
||||
roles = RoleMapper.Merge(result.Roles, dbRows);
|
||||
var mapping = await roleMapper.MapAsync(result.Groups, ct);
|
||||
roles = Union(result.Roles, mapping.Roles);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// A DB hiccup must never block sign-in — fall back to the appsettings baseline roles.
|
||||
// A DB hiccup (or any mapper fault) must never block sign-in — fall back to the
|
||||
// pre-resolved baseline roles (empty on the real path, FleetAdmin under DevStub).
|
||||
// This is intentionally FAIL-CLOSED on the real LDAP path: result.Roles is empty there
|
||||
// (the library returns groups, never roles — the mapper is the sole role source), so a
|
||||
// mapper fault signs the user in AUTHENTICATED but with ZERO role claims. They can prove
|
||||
// identity but are denied every role-gated action until the mapper recovers — strictly
|
||||
// safer than failing open with a stale/guessed role set. (See AuthEndpoints test
|
||||
// Login_when_role_mapper_throws_signs_in_with_no_role_claims.)
|
||||
http.RequestServices.GetService<ILoggerFactory>()?
|
||||
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||||
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline roles", username);
|
||||
.LogWarning(ex, "Role-map lookup failed for {User}; using pre-resolved baseline roles", username);
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
||||
// ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically.
|
||||
new(ZbClaimTypes.Name, result.Username ?? username),
|
||||
new(ZbClaimTypes.Username, result.Username ?? username),
|
||||
new(ZbClaimTypes.DisplayName, result.DisplayName ?? username),
|
||||
};
|
||||
foreach (var role in roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
// ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work.
|
||||
claims.Add(new Claim(ZbClaimTypes.Role, role));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
@@ -119,6 +135,21 @@ public static class AuthEndpoints
|
||||
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the
|
||||
/// legacy <c>RoleMapper.Merge</c> applied. Used to fold any pre-resolved roles (the DevStub
|
||||
/// FleetAdmin grant) into the mapper-resolved set.
|
||||
/// </summary>
|
||||
/// <param name="first">The first role set (pre-resolved baseline).</param>
|
||||
/// <param name="second">The second role set (mapper output).</param>
|
||||
private static IReadOnlyList<string> Union(IReadOnlyList<string> first, IReadOnlyList<string> second)
|
||||
{
|
||||
var roles = new HashSet<string>(first, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var role in second)
|
||||
roles.Add(role);
|
||||
return [.. roles];
|
||||
}
|
||||
|
||||
private static IResult Ping(HttpContext http) =>
|
||||
http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized();
|
||||
|
||||
@@ -129,7 +160,7 @@ public static class AuthEndpoints
|
||||
?? user.Identity?.Name
|
||||
?? string.Empty;
|
||||
var displayName = user.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username;
|
||||
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray();
|
||||
var roles = user.FindAll(ZbClaimTypes.Role).Select(c => c.Value).ToArray();
|
||||
|
||||
return Results.Ok(new TokenResponse(jwt.Issue(displayName, username, roles)));
|
||||
}
|
||||
|
||||
@@ -4,13 +4,47 @@ using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
|
||||
public sealed class JwtTokenService
|
||||
{
|
||||
public const string DisplayNameClaimType = "DisplayName";
|
||||
public const string UsernameClaimType = "Username";
|
||||
/// <summary>
|
||||
/// Alias of <see cref="ZbClaimTypes.DisplayName"/> — the canonical "zb:displayname" claim.
|
||||
/// All read and mint sites inherit the canonical spelling through this constant.
|
||||
/// </summary>
|
||||
public const string DisplayNameClaimType = ZbClaimTypes.DisplayName;
|
||||
|
||||
/// <summary>
|
||||
/// Alias of <see cref="ZbClaimTypes.Username"/> — the canonical "zb:username" claim.
|
||||
/// All read and mint sites inherit the canonical spelling through this constant.
|
||||
/// </summary>
|
||||
public const string UsernameClaimType = ZbClaimTypes.Username;
|
||||
|
||||
/// <summary>
|
||||
/// Role claim type used in the JWT payload.
|
||||
/// <para>
|
||||
/// <b>Issued-only / no internal JwtBearer scheme:</b> OtOpcUa uses a single Cookie
|
||||
/// authentication scheme; the JWT is minted by the <c>/auth/token</c> endpoint and
|
||||
/// consumed externally (e.g. by OPC-UA clients or automation scripts). There is no
|
||||
/// <c>AddJwtBearer</c> pipeline in OtOpcUa — the cookie stores the
|
||||
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> directly. Because no internal
|
||||
/// bearer validation path exists, the short "Role" key is intentionally used here rather
|
||||
/// than the long <see cref="ClaimTypes.Role"/> URI; external consumers receive exactly the
|
||||
/// key they expect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>If a JwtBearer scheme is ever added:</b> the
|
||||
/// <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> passed to
|
||||
/// <c>AddJwtBearer</c> MUST set <c>RoleClaimType = JwtTokenService.RoleClaimType</c> (and
|
||||
/// <c>NameClaimType = JwtTokenService.UsernameClaimType</c>) so that
|
||||
/// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve correctly.
|
||||
/// <see cref="BuildValidationParameters"/> is already wired to do this and MUST be used
|
||||
/// rather than constructing <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/>
|
||||
/// ad hoc.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public const string RoleClaimType = "Role";
|
||||
|
||||
private readonly JwtOptions _options;
|
||||
@@ -50,6 +84,8 @@ public sealed class JwtTokenService
|
||||
new(DisplayNameClaimType, displayName),
|
||||
new(UsernameClaimType, username),
|
||||
};
|
||||
// Role claims use the short RoleClaimType key ("Role") — see the <see cref="RoleClaimType"/>
|
||||
// doc comment for the issued-only rationale and the JwtBearer caveat.
|
||||
foreach (var role in roles)
|
||||
claims.Add(new Claim(RoleClaimType, role));
|
||||
|
||||
@@ -70,18 +106,9 @@ public sealed class JwtTokenService
|
||||
public bool TryValidate(string token, out ClaimsPrincipal? principal)
|
||||
{
|
||||
principal = null;
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _options.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _options.Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
// Delegate to BuildValidationParameters so RoleClaimType/NameClaimType are always in
|
||||
// sync with the mint constants — no risk of this method diverging from the bearer path.
|
||||
var parameters = BuildValidationParameters();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -99,6 +126,14 @@ public sealed class JwtTokenService
|
||||
/// <summary>
|
||||
/// Returns the validation parameters that the JwtBearer middleware should use. Centralised
|
||||
/// so the bearer pipeline can't drift from <see cref="TryValidate"/>.
|
||||
/// <para>
|
||||
/// <b>Note:</b> <see cref="TokenValidationParameters.RoleClaimType"/> is set to
|
||||
/// <see cref="RoleClaimType"/> and <see cref="TokenValidationParameters.NameClaimType"/> is
|
||||
/// set to <see cref="UsernameClaimType"/> so that <c>[Authorize(Roles=...)]</c> and
|
||||
/// <c>ClaimsPrincipal.IsInRole</c> resolve against the short role key ("Role") that
|
||||
/// <see cref="Issue"/> mints — not the JWT-default "role" or "name" keys. This is the
|
||||
/// required pairing whenever a JwtBearer scheme is wired.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public TokenValidationParameters BuildValidationParameters() => new()
|
||||
{
|
||||
@@ -110,5 +145,9 @@ public sealed class JwtTokenService
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)),
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
// Pair these with the constants used at mint time so role/name resolution is correct
|
||||
// if this is ever passed to AddJwtBearer. See RoleClaimType doc comment for rationale.
|
||||
RoleClaimType = RoleClaimType,
|
||||
NameClaimType = UsernameClaimType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
|
||||
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
|
||||
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
|
||||
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
|
||||
/// </summary>
|
||||
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
|
||||
: ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options = options.Value;
|
||||
|
||||
/// <summary>Authenticates a user via LDAP bind and retrieves their group memberships and roles.</summary>
|
||||
/// <param name="username">The username to authenticate.</param>
|
||||
/// <param name="password">The password to validate against the LDAP directory.</param>
|
||||
/// <param name="ct">A cancellation token to observe while waiting for the operation to complete.</param>
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, null, [], [], "Password is required");
|
||||
|
||||
if (_options.DevStubMode)
|
||||
{
|
||||
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||
}
|
||||
|
||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new LdapConnection();
|
||||
if (_options.UseTls) conn.SecureSocketLayer = true;
|
||||
|
||||
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
|
||||
|
||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
|
||||
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
|
||||
typesOnly: false), ct);
|
||||
|
||||
while (results.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = results.Next();
|
||||
var name = entry.GetAttribute(_options.DisplayNameAttribute);
|
||||
if (name is not null) displayName = name.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
|
||||
if (groupAttr is not null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
|
||||
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
|
||||
// directory doesn't populate memberOf (or populates it differently), the
|
||||
// user's primary group name is recoverable from the second RDN of the DN.
|
||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||
{
|
||||
var primary = ExtractOuSegment(entry.Dn);
|
||||
if (primary is not null) groups.Add(primary);
|
||||
}
|
||||
}
|
||||
catch (LdapException) { break; } // no-more-entries signalled by exception
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||
}
|
||||
|
||||
conn.Disconnect();
|
||||
|
||||
var roles = RoleMapper.Map(groups, _options.GroupToRole);
|
||||
return new(true, displayName, username, groups, roles, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
|
||||
return new(false, null, username, [], [], "Invalid username or password");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||
return new(false, null, username, [], [], "Unexpected authentication error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||
{
|
||||
if (username.Contains('=')) return username; // already a DN
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"({_options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
if (results.HasMore())
|
||||
return results.Next().Dn;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for {filter}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{_options.SearchBase}";
|
||||
}
|
||||
|
||||
/// <summary>Escapes special characters in an LDAP filter string according to RFC 4515.</summary>
|
||||
/// <param name="input">The unescaped string to escape.</param>
|
||||
/// <returns>The escaped LDAP filter string.</returns>
|
||||
internal static string EscapeLdapFilter(string input) =>
|
||||
input.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
|
||||
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
|
||||
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
|
||||
/// </summary>
|
||||
/// <param name="dn">The distinguished name to extract the OU from.</param>
|
||||
/// <returns>The extracted OU value, or null if no OU segment is found.</returns>
|
||||
internal static string? ExtractOuSegment(string dn)
|
||||
{
|
||||
var segments = dn.Split(',');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||
return trimmed[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the value portion of the first RDN (relative distinguished name) from a DN.</summary>
|
||||
/// <param name="dn">The distinguished name to extract from.</param>
|
||||
/// <returns>The value of the first RDN.</returns>
|
||||
internal static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
var equalsIdx = dn.IndexOf('=');
|
||||
if (equalsIdx < 0) return dn;
|
||||
|
||||
var valueStart = equalsIdx + 1;
|
||||
var commaIdx = dn.IndexOf(',', valueStart);
|
||||
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using LibLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP + role-mapping configuration for the Admin UI. Bound from <c>appsettings.json</c>
|
||||
/// <c>Authentication:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>Security:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>C:\publish\glauth\auth.md</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carries both the wire fields the shared <c>ZB.MOM.WW.Auth.Ldap</c> directory client needs
|
||||
/// (<see cref="Server"/>/<see cref="Port"/>/<see cref="Transport"/>/…) AND the app-only concerns
|
||||
/// the shared library has no notion of (<see cref="Enabled"/> master switch,
|
||||
/// <see cref="DevStubMode"/> dev bypass, <see cref="GroupToRole"/> appsettings role baseline).
|
||||
/// The app wrapper (<c>OtOpcUaLdapAuthService</c>) projects this onto the library's
|
||||
/// <see cref="LibLdapOptions"/> at construction; see <see cref="ToLibraryOptions"/>.
|
||||
/// </remarks>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Ldap";
|
||||
public const string SectionName = "Security:Ldap";
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether LDAP authentication is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
@@ -18,21 +29,28 @@ public sealed class LdapOptions
|
||||
/// <summary>Gets or sets the LDAP server port.</summary>
|
||||
public int Port { get; set; } = 3893;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether to use TLS for LDAP connection.</summary>
|
||||
public bool UseTls { get; set; }
|
||||
/// <summary>
|
||||
/// Transport security for the LDAP connection — <see cref="LdapTransport.Ldaps"/> (implicit
|
||||
/// TLS), <see cref="LdapTransport.StartTls"/> (upgrade), or <see cref="LdapTransport.None"/>
|
||||
/// (plaintext, dev/test only — requires <see cref="AllowInsecure"/>). Replaces the former
|
||||
/// <c>UseTls</c> bool (Task 1.4): <c>true</c>→<see cref="LdapTransport.Ldaps"/>,
|
||||
/// <c>false</c>→<see cref="LdapTransport.None"/>.
|
||||
/// </summary>
|
||||
public LdapTransport Transport { get; set; } = LdapTransport.None;
|
||||
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||
public bool AllowInsecureLdap { get; set; }
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production. Maps to the shared
|
||||
/// library's <see cref="LibLdapOptions.AllowInsecure"/> (renamed from <c>AllowInsecureLdap</c>).</summary>
|
||||
public bool AllowInsecure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
|
||||
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
||||
/// Dev-only stub: when <c>true</c>, <see cref="OtOpcUaLdapAuthService"/> bypasses the real LDAP
|
||||
/// bind and accepts any non-empty username/password, returning a single Administrator role
|
||||
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
||||
/// </summary>
|
||||
public bool DevStubMode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the LDAP search base DN.</summary>
|
||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||
public string SearchBase { get; set; } = "dc=zb,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Service-account DN used for search-then-bind. When empty, a direct-bind with
|
||||
@@ -58,8 +76,31 @@ public sealed class LdapOptions
|
||||
|
||||
/// <summary>
|
||||
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
|
||||
/// role whose source group is in their membership list. Example dev mapping:
|
||||
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||
/// role whose source group is in their membership list. Values are the canonical control-plane
|
||||
/// roles (Task 1.7). Example dev mapping:
|
||||
/// <code>"ReadOnly":"Viewer","ReadWrite":"Designer","AlarmAck":"Administrator"</code>
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire fields onto the shared <c>ZB.MOM.WW.Auth.Ldap</c>
|
||||
/// <see cref="LibLdapOptions"/> the directory client consumes. App-only concerns
|
||||
/// (<see cref="DevStubMode"/>, <see cref="GroupToRole"/>) have no library counterpart and are
|
||||
/// handled by the app wrapper around the library service; <see cref="Enabled"/> is carried
|
||||
/// through so the library's own feature gate stays consistent with the app master switch.
|
||||
/// </summary>
|
||||
public LibLdapOptions ToLibraryOptions() => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Server = Server,
|
||||
Port = Port,
|
||||
Transport = Transport,
|
||||
AllowInsecure = AllowInsecure,
|
||||
SearchBase = SearchBase,
|
||||
ServiceAccountDn = ServiceAccountDn,
|
||||
ServiceAccountPassword = ServiceAccountPassword,
|
||||
UserNameAttribute = UserNameAttribute,
|
||||
DisplayNameAttribute = DisplayNameAttribute,
|
||||
GroupAttribute = GroupAttribute,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// OtOpcUa's <see cref="IGroupRoleMapper{TRole}"/> implementation (roles are plain strings,
|
||||
/// so <c>TRole = string</c>). A thin, behaviour-preserving adapter over the existing
|
||||
/// <see cref="RoleMapper"/>: it computes the appsettings baseline via
|
||||
/// <see cref="RoleMapper.Map"/>, then unions in system-wide DB grants via
|
||||
/// <see cref="RoleMapper.Merge"/>. The OtOpcUa authz model is global (no per-cluster scope at
|
||||
/// login), so <see cref="GroupRoleMapping{TRole}.Scope"/> is always <c>null</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the shared-library seam introduced ahead of rewiring the login flow; it does not
|
||||
/// duplicate mapping logic and does not change behaviour. See <c>scadaproj/components/auth</c>.
|
||||
/// </remarks>
|
||||
public sealed class OtOpcUaGroupRoleMapper(
|
||||
IOptions<LdapOptions> ldapOptions,
|
||||
ILdapGroupRoleMappingService dbMappings) : IGroupRoleMapper<string>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(groups);
|
||||
|
||||
var baseline = RoleMapper.Map(groups, ldapOptions.Value.GroupToRole);
|
||||
var dbRows = await dbMappings.GetByGroupsAsync(groups, ct).ConfigureAwait(false);
|
||||
var merged = RoleMapper.Merge(baseline, dbRows);
|
||||
|
||||
return new GroupRoleMapping<string>(merged, Scope: null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
|
||||
using LibLdapAuthService = ZB.MOM.WW.Auth.Ldap.LdapAuthService;
|
||||
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// OtOpcUa's application <see cref="ILdapAuthService"/> — a thin wrapper around the shared
|
||||
/// <c>ZB.MOM.WW.Auth.Ldap</c> directory client that adds the two app-only concerns the shared
|
||||
/// library deliberately does not model:
|
||||
/// <list type="number">
|
||||
/// <item>the <see cref="LdapOptions.Enabled"/> master switch (disabled ⇒ deny, no bind); and</item>
|
||||
/// <item><see cref="LdapOptions.DevStubMode"/> — the dev bypass that grants an Administrator
|
||||
/// session WITHOUT touching the network, so an operator can navigate the full Admin UI
|
||||
/// against a machine with no directory.</item>
|
||||
/// </list>
|
||||
/// On the real path it delegates to the library <see cref="LibLdapAuthService"/> and adapts the
|
||||
/// library result (which returns <em>groups</em>, never roles) back onto the app's
|
||||
/// <see cref="LdapAuthResult"/> shape. Role resolution itself now lives downstream in
|
||||
/// <see cref="OtOpcUaGroupRoleMapper"/> (the <c>IGroupRoleMapper<string></c> seam), which
|
||||
/// both the login endpoint and the OPC UA data-plane authenticator call with the returned
|
||||
/// <see cref="LdapAuthResult.Groups"/>. The only path that pre-populates
|
||||
/// <see cref="LdapAuthResult.Roles"/> is the DevStub success; consumers union that pre-resolved
|
||||
/// set with the mapper output so the dev Administrator grant survives the move to the mapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
|
||||
/// DevStub result grants the canonical <c>"Administrator"</c> control-plane role (group
|
||||
/// <c>"dev"</c>) so the dev session can navigate the full Admin UI (Task 1.7 renamed the prior
|
||||
/// <c>"FleetAdmin"</c> to the canonical <c>"Administrator"</c>).
|
||||
/// </remarks>
|
||||
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options;
|
||||
private readonly LibILdapAuthService _inner;
|
||||
private readonly ILogger<OtOpcUaLdapAuthService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: builds the shared-library directory client from the wire fields
|
||||
/// of the bound app <see cref="LdapOptions"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The app LDAP options (wire fields + app-only concerns).</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public OtOpcUaLdapAuthService(IOptions<LdapOptions> options, ILogger<OtOpcUaLdapAuthService> logger)
|
||||
: this(options.Value, new LibLdapAuthService(options.Value.ToLibraryOptions()), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seam constructor: accepts an injected library <see cref="LibILdapAuthService"/> so the
|
||||
/// Enabled/DevStub/delegation logic can be unit-tested without a live directory.
|
||||
/// </summary>
|
||||
/// <param name="options">The app LDAP options.</param>
|
||||
/// <param name="inner">The shared-library directory client to delegate the real path to.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
internal OtOpcUaLdapAuthService(LdapOptions options, LibILdapAuthService inner, ILogger<OtOpcUaLdapAuthService> logger)
|
||||
{
|
||||
_options = options;
|
||||
_inner = inner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
|
||||
// refuse to authenticate at all (no bind, no dev-stub bypass).
|
||||
if (!_options.Enabled)
|
||||
return new(false, null, null, [], [], "LDAP authentication is disabled.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, username, [], [], "Password is required");
|
||||
|
||||
if (_options.DevStubMode)
|
||||
{
|
||||
// Dev bypass: accept any non-empty credentials and grant Administrator WITHOUT a real bind.
|
||||
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
|
||||
// survives the move to IGroupRoleMapper. (Task 1.7 canonicalized the role string from the
|
||||
// prior "FleetAdmin" to "Administrator".)
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||
return new(true, username, username, ["dev"], ["Administrator"], null);
|
||||
}
|
||||
|
||||
// Fail closed on a plaintext transport unless explicitly opted in. The bespoke service
|
||||
// enforced this at login (not startup), so the host still boots with an insecure-by-default
|
||||
// config and only refuses the bind here — preserved verbatim after the UseTls→Transport
|
||||
// migration (Task 1.4). The shared library's directory client does not re-check this.
|
||||
if (_options.Transport == LdapTransport.None && !_options.AllowInsecure)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable a TLS transport or set AllowInsecure for dev/test.");
|
||||
|
||||
var libResult = await _inner.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
|
||||
return Adapt(libResult, username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the shared-library <see cref="LibLdapAuthResult"/> onto the app's
|
||||
/// <see cref="LdapAuthResult"/>. The library returns groups (never roles) on success, so
|
||||
/// <see cref="LdapAuthResult.Roles"/> is left empty on the delegated path — role resolution
|
||||
/// happens downstream in the mapper. Library <see cref="LdapAuthFailure"/> codes are folded
|
||||
/// into the user-facing error strings the app already surfaces, keeping fail-closed semantics.
|
||||
/// </summary>
|
||||
/// <param name="result">The library authentication result.</param>
|
||||
/// <param name="username">The login name, used to populate the app result's Username field.</param>
|
||||
private static LdapAuthResult Adapt(LibLdapAuthResult result, string username)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
return new(true, result.DisplayName, result.Username, result.Groups, [], null);
|
||||
|
||||
return new(false, null, username, [], [], FailureToError(result.Failure));
|
||||
}
|
||||
|
||||
/// <summary>Folds a structured library failure code into the app's user-facing error text.</summary>
|
||||
/// <param name="failure">The library failure code (null defensively treated as a generic error).</param>
|
||||
private static string FailureToError(LdapAuthFailure? failure) => failure switch
|
||||
{
|
||||
// The directory found no single matching user, or the password did not verify — both
|
||||
// surface as the same opaque message so a probe cannot distinguish "unknown user" from
|
||||
// "wrong password".
|
||||
LdapAuthFailure.BadCredentials => "Invalid username or password",
|
||||
LdapAuthFailure.UserNotFound => "Invalid username or password",
|
||||
LdapAuthFailure.AmbiguousUser => "Invalid username or password",
|
||||
LdapAuthFailure.GroupLookupFailed => "Invalid username or password",
|
||||
// System-side faults (directory unreachable / service-account misconfiguration) — kept as a
|
||||
// generic backend message rather than leaking the cause to the caller.
|
||||
LdapAuthFailure.ServiceAccountBindFailed => "Unexpected authentication error",
|
||||
LdapAuthFailure.Disabled => "LDAP authentication is disabled.",
|
||||
_ => "Unexpected authentication error",
|
||||
};
|
||||
}
|
||||
@@ -4,9 +4,13 @@ using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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;
|
||||
|
||||
@@ -34,9 +38,32 @@ public static class ServiceCollectionExtensions
|
||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
// 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<IAuditActorAccessor, HttpAuditActorAccessor>();
|
||||
|
||||
// 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
|
||||
// adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap
|
||||
// service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
|
||||
// driver path) ends up with exactly one descriptor regardless of registration order.
|
||||
services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||
|
||||
// Shared ZB.MOM.WW.Auth group→role mapper seam (Task 1.1, additive). Wraps the existing
|
||||
// RoleMapper.Map + RoleMapper.Merge logic; the login flow is rewired to consume it in a
|
||||
// later task. Scoped to match ILdapGroupRoleMappingService (DbContext-backed, registered
|
||||
// Scoped) — a singleton here would capture the scoped DB service.
|
||||
services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
@@ -45,28 +72,34 @@ public static class ServiceCollectionExtensions
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration /
|
||||
// HttpOnly / SameSite are applied from OtOpcUaCookieOptions via ZbCookieDefaults
|
||||
// in the PostConfigure block below.
|
||||
o.LoginPath = "/login";
|
||||
o.LogoutPath = "/auth/logout";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||
});
|
||||
|
||||
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||
// ZbCookieDefaults.Apply sets HttpOnly=true, SameSite=Strict, SlidingExpiration=true,
|
||||
// SecurePolicy, and ExpireTimeSpan; we then set the app-specific cookie name on top.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||
{
|
||||
var v = ourOpts.Value;
|
||||
|
||||
// Apply canonical hardened defaults (HttpOnly, SameSite=Strict, SlidingExpiration,
|
||||
// SecurePolicy, ExpireTimeSpan). Cookie name is NOT touched by ZbCookieDefaults —
|
||||
// we set it below so each app keeps its own distinct cookie name.
|
||||
ZbCookieDefaults.Apply(
|
||||
cookieOpts,
|
||||
requireHttps: v.RequireHttpsCookie,
|
||||
idleTimeout: TimeSpan.FromMinutes(v.ExpiryMinutes));
|
||||
|
||||
// Keep OtOpcUa's own cookie name (default "ZB.MOM.WW.OtOpcUa.Auth").
|
||||
cookieOpts.Cookie.Name = v.Name;
|
||||
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||
cookieOpts.SlidingExpiration = true;
|
||||
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
if (!v.RequireHttpsCookie)
|
||||
{
|
||||
@@ -84,14 +117,17 @@ public static class ServiceCollectionExtensions
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
// DriverOperator: may issue Reconnect/Restart commands against live driver instances
|
||||
// from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
|
||||
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
||||
// DriverOperator (policy NAME kept stable): may issue Reconnect/Restart commands against
|
||||
// live driver instances from the Admin UI DriverStatusPanel. The role STRINGS it requires
|
||||
// are the canonical control-plane roles (Task 1.7): Operator (was DriverOperator) and
|
||||
// Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings
|
||||
// (e.g. "ot-driver-operator": "Operator").
|
||||
o.AddPolicy("DriverOperator", policy =>
|
||||
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
||||
policy.RequireRole("Operator", "Administrator"));
|
||||
|
||||
// FleetAdmin: full administrative access; gates fleet-wide pages such as RoleGrants.
|
||||
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin"));
|
||||
// FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages
|
||||
// such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin).
|
||||
o.AddPolicy("FleetAdmin", policy => policy.RequireRole("Administrator"));
|
||||
});
|
||||
|
||||
return services;
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Queries;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="ClusterAuditQuery.ForClusterAsync"/> — the cluster-scoped audit view used
|
||||
/// by the AdminUI ClusterAudit page. The structured AuditWriterActor path stamps NodeId (not
|
||||
/// ClusterId), so before the Task 2.2 fix those rows were invisible to a cluster filtered only on
|
||||
/// ClusterId. These tests pin the OR-predicate that joins NodeId back to its cluster.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ClusterAuditQueryTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
/// <summary>Initializes a new instance with a fresh in-memory config database.</summary>
|
||||
public ClusterAuditQueryTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"cluster-audit-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
/// <summary>Disposes the database context.</summary>
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private void SeedNode(string clusterId, string nodeId) =>
|
||||
_db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = clusterId,
|
||||
Host = $"{nodeId}.local",
|
||||
ApplicationUri = $"urn:{nodeId}",
|
||||
CreatedBy = "test",
|
||||
});
|
||||
|
||||
private void SeedAudit(string eventType, string? clusterId, string? nodeId, DateTime ts) =>
|
||||
_db.ConfigAuditLogs.Add(new ConfigAuditLog
|
||||
{
|
||||
Principal = "tester",
|
||||
EventType = eventType,
|
||||
ClusterId = clusterId,
|
||||
NodeId = nodeId,
|
||||
Timestamp = ts,
|
||||
});
|
||||
|
||||
/// <summary>Structured rows (ClusterId null, NodeId set) for a node in the cluster are now
|
||||
/// visible, alongside the SP-path rows that stamp ClusterId directly.</summary>
|
||||
[Fact]
|
||||
public async Task Surfaces_both_clusterId_rows_and_structured_nodeId_rows()
|
||||
{
|
||||
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
|
||||
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-B");
|
||||
SeedNode("OTHER-CLUSTER", "OTHER-A");
|
||||
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// SP path: stamps ClusterId.
|
||||
SeedAudit("Published", clusterId: "LINE3-OPCUA", nodeId: null, ts: t0);
|
||||
// Structured AuditWriterActor path: stamps NodeId, ClusterId null — these were invisible.
|
||||
SeedAudit("DraftEdited", clusterId: null, nodeId: "LINE3-OPCUA-A", ts: t0.AddMinutes(1));
|
||||
SeedAudit("NodeApplied", clusterId: null, nodeId: "LINE3-OPCUA-B", ts: t0.AddMinutes(2));
|
||||
// Noise that must NOT appear: other cluster's structured row + an orphan NodeId.
|
||||
SeedAudit("Published", clusterId: null, nodeId: "OTHER-A", ts: t0.AddMinutes(3));
|
||||
SeedAudit("Published", clusterId: null, nodeId: "UNKNOWN-NODE", ts: t0.AddMinutes(4));
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 200);
|
||||
|
||||
rows.Select(r => r.EventType).ShouldBe(
|
||||
["NodeApplied", "DraftEdited", "Published"], // newest first
|
||||
ignoreOrder: false);
|
||||
}
|
||||
|
||||
/// <summary>An audit row stamped with another cluster's ClusterId never appears.</summary>
|
||||
[Fact]
|
||||
public async Task Does_not_surface_other_cluster_rows()
|
||||
{
|
||||
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
|
||||
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
|
||||
SeedAudit("Published", clusterId: "OTHER-CLUSTER", nodeId: null, ts: t0);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 200);
|
||||
|
||||
rows.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Respects the page-size cap, newest first.</summary>
|
||||
[Fact]
|
||||
public async Task Caps_to_page_size_newest_first()
|
||||
{
|
||||
SeedNode("LINE3-OPCUA", "LINE3-OPCUA-A");
|
||||
var t0 = new DateTime(2026, 6, 2, 10, 0, 0, DateTimeKind.Utc);
|
||||
for (var i = 0; i < 5; i++)
|
||||
SeedAudit("DraftEdited", clusterId: null, nodeId: "LINE3-OPCUA-A", ts: t0.AddMinutes(i));
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var rows = await ClusterAuditQuery.ForClusterAsync(_db, "LINE3-OPCUA", pageSize: 3);
|
||||
|
||||
rows.Count.ShouldBe(3);
|
||||
rows.First().Timestamp.ShouldBe(t0.AddMinutes(4)); // newest
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -38,7 +38,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_SetsId_AndCreatedAtUtc()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=fleet,dc=x", AdminRole.FleetAdmin);
|
||||
var row = Make("cn=fleet,dc=x", AdminRole.Administrator);
|
||||
|
||||
var saved = await svc.CreateAsync(row, CancellationToken.None);
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_Rejects_EmptyLdapGroup()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("", AdminRole.FleetAdmin);
|
||||
var row = Make("", AdminRole.Administrator);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
@@ -62,7 +62,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_Rejects_SystemWide_With_ClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: "c1", isSystemWide: true);
|
||||
var row = Make("cn=g", AdminRole.Viewer, clusterId: "c1", isSystemWide: true);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
@@ -73,7 +73,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: null, isSystemWide: false);
|
||||
var row = Make("cn=g", AdminRole.Viewer, clusterId: null, isSystemWide: false);
|
||||
|
||||
await Should.ThrowAsync<InvalidLdapGroupRoleMappingException>(
|
||||
() => svc.CreateAsync(row, CancellationToken.None));
|
||||
@@ -84,15 +84,15 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task GetByGroups_Returns_MatchingGrants_Only()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.ConfigEditor), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.ConfigViewer), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.Designer), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.Viewer), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync(
|
||||
["cn=fleet,dc=x", "cn=viewer,dc=x"], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.Select(r => r.Role).ShouldBe([AdminRole.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
|
||||
results.Select(r => r.Role).ShouldBe([AdminRole.Administrator, AdminRole.Viewer], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetByGroups returns empty when input is empty.</summary>
|
||||
@@ -100,7 +100,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
|
||||
var results = await svc.GetByGroupsAsync([], CancellationToken.None);
|
||||
|
||||
@@ -112,9 +112,9 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task ListAll_Orders_ByGroupThenCluster()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c2", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c1", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c2", isSystemWide: false), CancellationToken.None);
|
||||
await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c1", isSystemWide: false), CancellationToken.None);
|
||||
|
||||
var results = await svc.ListAllAsync(CancellationToken.None);
|
||||
|
||||
@@ -129,7 +129,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
public async Task Delete_Removes_Matching_Row()
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
|
||||
var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None);
|
||||
|
||||
await svc.DeleteAsync(saved.Id, CancellationToken.None);
|
||||
|
||||
@@ -153,7 +153,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
|
||||
{
|
||||
var svc = new LdapGroupRoleMappingService(_db);
|
||||
var saved = await svc.CreateAsync(
|
||||
Make("cn=sysadmins,dc=x", AdminRole.FleetAdmin, clusterId: null, isSystemWide: true),
|
||||
Make("cn=sysadmins,dc=x", AdminRole.Administrator, clusterId: null, isSystemWide: true),
|
||||
CancellationToken.None);
|
||||
|
||||
saved.IsSystemWide.ShouldBeTrue();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
|
||||
@@ -11,15 +10,18 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
|
||||
{
|
||||
private static AuditEvent NewEvent(Guid eventId, string action = "Edit", string actor = "joe") =>
|
||||
new(
|
||||
eventId,
|
||||
"Config",
|
||||
action,
|
||||
actor,
|
||||
DateTime.UtcNow,
|
||||
DetailsJson: "{\"field\":\"value\"}",
|
||||
SourceNode: NodeId.Parse("node-a"),
|
||||
CorrelationId: CorrelationId.NewId());
|
||||
new()
|
||||
{
|
||||
EventId = eventId,
|
||||
Category = "Config",
|
||||
Action = action,
|
||||
Actor = actor,
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Outcome = AuditOutcomeMapper.FromAction(action),
|
||||
DetailsJson = "{\"field\":\"value\"}",
|
||||
SourceNode = "node-a",
|
||||
CorrelationId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
/// <summary>Verifies that buffered events flush when count threshold is reached.</summary>
|
||||
[Fact]
|
||||
@@ -102,4 +104,112 @@ public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
|
||||
row.EventType.ShouldBe("Config:Edit");
|
||||
row.NodeId.ShouldBe("node-a");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a null SourceNode/CorrelationId on the canonical event persists as null
|
||||
/// (the canonical fields are now nullable; the actor must not assume they are set).</summary>
|
||||
[Fact]
|
||||
public void Null_sourceNode_and_correlationId_persist_as_null()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
|
||||
|
||||
actor.Tell(new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
Category = "Config",
|
||||
Action = "Published",
|
||||
Actor = "joe",
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Outcome = AuditOutcome.Success,
|
||||
SourceNode = null,
|
||||
CorrelationId = null,
|
||||
});
|
||||
|
||||
Watch(actor);
|
||||
actor.Tell(PoisonPill.Instance);
|
||||
ExpectTerminated(actor);
|
||||
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
var row = db.ConfigAuditLogs.Single();
|
||||
row.NodeId.ShouldBeNull();
|
||||
row.CorrelationId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the IAuditWriter.WriteAsync seam is best-effort: it completes
|
||||
/// synchronously, never throws, and routes the event onto the actor's own mailbox
|
||||
/// (<c>Self.Tell</c>) — i.e. the same buffer + dedup + flush pipeline asserted by the Tell
|
||||
/// tests above. Reaches the concrete instance via a TestActorRef.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAsync_is_best_effort_and_routes_onto_the_actor_mailbox()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var testRef = ActorOfAsTestActorRef<AuditWriterActor>(AuditWriterActor.Props(dbFactory));
|
||||
IAuditWriter writer = testRef.UnderlyingActor;
|
||||
|
||||
var task = writer.WriteAsync(NewEvent(Guid.NewGuid(), action: "Published"));
|
||||
task.IsCompletedSuccessfully.ShouldBeTrue("WriteAsync must be best-effort and complete synchronously");
|
||||
await Should.NotThrowAsync(async () => await task);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an AuditEvent delivered to the actor's mailbox — which is exactly
|
||||
/// what the WriteAsync seam does via Self.Tell — is buffered and persisted with the canonical
|
||||
/// fields intact.</summary>
|
||||
[Fact]
|
||||
public void Mailbox_delivery_persists_the_canonical_fields()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
actor.Tell(NewEvent(eventId, action: "Published"));
|
||||
|
||||
Watch(actor);
|
||||
actor.Tell(PoisonPill.Instance);
|
||||
ExpectTerminated(actor);
|
||||
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
var row = db.ConfigAuditLogs.Single();
|
||||
row.EventId.ShouldBe(eventId);
|
||||
row.EventType.ShouldBe("Config:Published");
|
||||
row.NodeId.ShouldBe("node-a");
|
||||
// The derived canonical Outcome is persisted as its enum member name (Task 2.2 column).
|
||||
row.Outcome.ShouldBe(nameof(AuditOutcome.Success));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Denied-outcome event persists "Denied" to the Outcome column.</summary>
|
||||
[Fact]
|
||||
public void Denied_outcome_is_persisted_as_its_enum_member_name()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));
|
||||
|
||||
actor.Tell(NewEvent(Guid.NewGuid(), action: "OpcUaAccessDenied"));
|
||||
|
||||
Watch(actor);
|
||||
actor.Tell(PoisonPill.Instance);
|
||||
ExpectTerminated(actor);
|
||||
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
var row = db.ConfigAuditLogs.Single();
|
||||
row.Outcome.ShouldBe(nameof(AuditOutcome.Denied));
|
||||
row.EventType.ShouldBe("Config:OpcUaAccessDenied");
|
||||
}
|
||||
|
||||
/// <summary>Verifies the Outcome derivation table: config verbs → Success, the two
|
||||
/// authorization-rejection events → Denied.</summary>
|
||||
[Theory]
|
||||
[InlineData("DraftCreated", AuditOutcome.Success)]
|
||||
[InlineData("DraftEdited", AuditOutcome.Success)]
|
||||
[InlineData("Published", AuditOutcome.Success)]
|
||||
[InlineData("RolledBack", AuditOutcome.Success)]
|
||||
[InlineData("NodeApplied", AuditOutcome.Success)]
|
||||
[InlineData("ClusterCreated", AuditOutcome.Success)]
|
||||
[InlineData("NodeAdded", AuditOutcome.Success)]
|
||||
[InlineData("CredentialAdded", AuditOutcome.Success)]
|
||||
[InlineData("CredentialDisabled", AuditOutcome.Success)]
|
||||
[InlineData("ExternalIdReleased", AuditOutcome.Success)]
|
||||
[InlineData("OpcUaAccessDenied", AuditOutcome.Denied)]
|
||||
[InlineData("CrossClusterNamespaceAttempt", AuditOutcome.Denied)]
|
||||
public void Outcome_is_derived_from_the_action_vocabulary(string action, AuditOutcome expected) =>
|
||||
AuditOutcomeMapper.FromAction(action).ShouldBe(expected);
|
||||
}
|
||||
|
||||
+73
-12
@@ -1,30 +1,70 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
|
||||
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
|
||||
/// backend exceptions into a denial rather than letting them escape into the SDK.
|
||||
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
|
||||
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
|
||||
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
|
||||
/// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather
|
||||
/// than letting them escape into the SDK.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
/// <summary>Verifies that successful LDAP authentication returns Allow result with user roles.</summary>
|
||||
/// <summary>On success the data-plane authenticator resolves roles via the mapper from the
|
||||
/// returned Groups — not from the auth result's Roles field — and grants identity.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
|
||||
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
|
||||
// group "configeditor" -> "Designer" (canonical, Task 1.7).
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.DisplayName.ShouldBe("Alice");
|
||||
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
||||
result.Roles.ShouldBe(new[] { "Designer" });
|
||||
}
|
||||
|
||||
/// <summary>The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are
|
||||
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
|
||||
{
|
||||
// DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to
|
||||
// nothing, so the union is exactly {Administrator}.
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
|
||||
var mapper = new FakeMapper(_ => Array.Empty<string>());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
|
||||
/// back to the pre-resolved roles, matching the login endpoint's behaviour.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null));
|
||||
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
|
||||
@@ -32,7 +72,8 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||
|
||||
@@ -45,7 +86,8 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
||||
{
|
||||
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||
|
||||
@@ -58,8 +100,9 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
[Fact]
|
||||
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||
|
||||
@@ -67,6 +110,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
result.DisplayName.ShouldBe("alice");
|
||||
}
|
||||
|
||||
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
|
||||
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mapper);
|
||||
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||
}
|
||||
|
||||
/// <summary>Test fake implementation of LDAP authentication service.</summary>
|
||||
private sealed class FakeLdap : ILdapAuthService
|
||||
{
|
||||
@@ -87,4 +138,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
=> Task.FromResult(_handler(username));
|
||||
}
|
||||
|
||||
/// <summary>Test fake group→role mapper driven by a delegate over the supplied groups.</summary>
|
||||
private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
|
||||
{
|
||||
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
|
||||
/// <param name="groups">The LDAP groups to map.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
||||
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for the LDAP config-section fix. The real config (admin/driver/Development
|
||||
/// overlays) lives under <c>Security:Ldap</c>, and <see cref="LdapOptions.SectionName"/> must point
|
||||
/// there so the configured <c>DevStubMode</c> actually binds. Previously the binders used the
|
||||
/// nonexistent <c>"Ldap"</c>/<c>"Authentication:Ldap"</c> sections, so the dev stub never activated.
|
||||
/// </summary>
|
||||
public sealed class LdapOptionsBindingTests
|
||||
{
|
||||
/// <summary><see cref="LdapOptions.SectionName"/> resolves to the real overlay section.</summary>
|
||||
[Fact]
|
||||
public void SectionName_is_Security_Ldap()
|
||||
{
|
||||
LdapOptions.SectionName.ShouldBe("Security:Ldap");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binding from <see cref="LdapOptions.SectionName"/> reads the configured <c>DevStubMode</c>
|
||||
/// from the real <c>Security:Ldap</c> section — proving the dev stub now takes effect.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Binding_from_SectionName_reads_Security_Ldap_DevStubMode()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Ldap:DevStubMode"] = "true",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>();
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options.DevStubMode.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Negative control: binding from the old (nonexistent) <c>"Ldap"</c> section against the same
|
||||
/// <c>Security:Ldap</c> config does NOT pick up <c>DevStubMode</c> — it falls back to the C#
|
||||
/// default (false). This is the pre-fix behaviour the change corrects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Binding_from_old_Ldap_section_does_not_read_DevStubMode()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Ldap:DevStubMode"] = "true",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection("Ldap").Get<LdapOptions>() ?? new LdapOptions();
|
||||
|
||||
options.DevStubMode.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end guard for the shipped production overlays: binds each of the three prod overlay
|
||||
/// files' real <c>Security:Ldap</c> section (the same files the host loads at boot, copied into the
|
||||
/// test output via the Host project reference) and runs the <see cref="LdapOptionsValidator"/> the
|
||||
/// host wires via <c>AddValidatedOptions</c>. Proves each prod overlay declares a TLS transport and
|
||||
/// therefore PASSES startup validation — i.e. the host actually boots with these overlays after the
|
||||
/// insecure-transport guard was added. The <c>Development</c> overlay (DevStubMode) is verified to
|
||||
/// pass via the guard exemption.
|
||||
/// </summary>
|
||||
public sealed class ProdOverlayValidationTests
|
||||
{
|
||||
private static readonly LdapOptionsValidator Sut = new();
|
||||
|
||||
private static LdapOptions BindOverlay(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
File.Exists(path).ShouldBeTrue($"overlay '{fileName}' should be copied to the test output");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(path, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
return configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>() ?? new LdapOptions();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("appsettings.admin.json")]
|
||||
[InlineData("appsettings.driver.json")]
|
||||
[InlineData("appsettings.admin-driver.json")]
|
||||
public void Prod_overlay_declares_ldaps_transport(string fileName)
|
||||
{
|
||||
var options = BindOverlay(fileName);
|
||||
|
||||
options.DevStubMode.ShouldBeFalse();
|
||||
options.Transport.ShouldBe(LdapTransport.Ldaps);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("appsettings.admin.json")]
|
||||
[InlineData("appsettings.driver.json")]
|
||||
[InlineData("appsettings.admin-driver.json")]
|
||||
public void Prod_overlay_passes_startup_validation(string fileName)
|
||||
{
|
||||
var options = BindOverlay(fileName);
|
||||
|
||||
// Match the host: these overlays only set Security:Ldap fields, so backfill the required
|
||||
// Server/SearchBase/Port the way the base C# defaults do (LdapOptions defaults are valid),
|
||||
// then validate exactly as AddValidatedOptions would at boot.
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Development_overlay_passes_startup_validation_via_devstub_exemption()
|
||||
{
|
||||
var options = BindOverlay("appsettings.Development.json");
|
||||
|
||||
options.DevStubMode.ShouldBeTrue();
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 3 — verifies the net-new <see cref="LdapOptionsValidator"/> (built on the shared
|
||||
/// <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>) gates on
|
||||
/// <see cref="LdapOptions.Enabled"/>, and that when enabled it requires <c>Server</c>,
|
||||
/// <c>SearchBase</c>, and a valid <c>Port</c>. Failure messages carry the real <c>"Ldap:"</c>
|
||||
/// section prefix so they read correctly when surfaced at host startup. Also verifies the
|
||||
/// insecure-transport startup guard: a real-LDAP config selecting plaintext transport without
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> fails fast at boot.
|
||||
/// </summary>
|
||||
public sealed class LdapOptionsValidatorTests
|
||||
{
|
||||
private static readonly LdapOptionsValidator Sut = new();
|
||||
|
||||
private const string InsecureTransportFailure =
|
||||
"LDAP transport is None (plaintext) but AllowInsecure is false — set Transport to Ldaps/StartTls or set AllowInsecure for dev.";
|
||||
|
||||
/// <summary>Valid enabled options (a TLS transport) pass validation.</summary>
|
||||
[Fact]
|
||||
public void Valid_enabled_options_succeed()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Insecure-transport guard: an enabled real-LDAP config that selects plaintext
|
||||
/// <see cref="LdapTransport.None"/> without <see cref="LdapOptions.AllowInsecure"/> fails
|
||||
/// startup validation with the guard message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Enabled_with_plaintext_transport_and_not_allow_insecure_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = false,
|
||||
};
|
||||
|
||||
var result = Sut.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(InsecureTransportFailure);
|
||||
}
|
||||
|
||||
/// <summary>A TLS transport (<see cref="LdapTransport.Ldaps"/>) satisfies the guard.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_ldaps_transport_passes_guard()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 636,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explicit opt-in: plaintext transport with <see cref="LdapOptions.AllowInsecure"/> set is
|
||||
/// permitted (dev/test escape hatch), so the guard does not trip.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Enabled_plaintext_with_allow_insecure_passes_guard()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DevStubMode is exempt from the insecure-transport guard: the dev stub bypasses the real
|
||||
/// bind, so plaintext transport is irrelevant and must not block boot.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DevStubMode_with_plaintext_transport_passes_guard()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DevStubMode = true,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = false,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A disabled config is exempt from the insecure-transport guard even with plaintext
|
||||
/// transport — LDAP login never runs, so the guard must not trip.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Disabled_with_plaintext_transport_passes_guard()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = false,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>When LDAP is disabled all checks are skipped, so a blank config still passes.</summary>
|
||||
[Fact]
|
||||
public void Disabled_options_succeed_even_when_blank()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
Server = string.Empty,
|
||||
SearchBase = string.Empty,
|
||||
Port = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the dev stub is active the real LDAP fields are irrelevant (the bind is bypassed), so
|
||||
/// the gate skips the Server/SearchBase/Port checks even though LDAP is nominally enabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DevStubMode_options_succeed_even_when_server_blank()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DevStubMode = true,
|
||||
Server = string.Empty,
|
||||
SearchBase = string.Empty,
|
||||
Port = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a blank server reports the required-server failure.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_blank_server_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = string.Empty,
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
};
|
||||
|
||||
var result = Sut.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("Ldap:Server is required when LDAP login is enabled.");
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a blank search base reports the required-search-base failure.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_blank_search_base_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = string.Empty,
|
||||
Port = 389,
|
||||
};
|
||||
|
||||
var result = Sut.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("Ldap:SearchBase is required when LDAP login is enabled.");
|
||||
}
|
||||
|
||||
/// <summary>Enabled with port 0 reports the port-range failure using the shared primitive wording.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_zero_port_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 0,
|
||||
};
|
||||
|
||||
var result = Sut.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("Ldap:Port must be between 1 and 65535 (was 0)");
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 4 — verifies the net-new <see cref="OpcUaApplicationHostOptionsValidator"/> (built on the
|
||||
/// shared <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>) that
|
||||
/// gates the OPC UA host options at startup. The C# defaults are all valid so a host with no
|
||||
/// explicit <c>"OpcUa"</c> section still passes; the validator exists to reject explicit
|
||||
/// prod/env overrides. Failure messages carry the real <c>"OpcUa:"</c> section prefix and the
|
||||
/// exact shared-primitive wording so they read correctly when surfaced via <c>ValidateOnStart</c>.
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHostOptionsValidatorTests
|
||||
{
|
||||
private static readonly OpcUaApplicationHostOptionsValidator Sut = new();
|
||||
|
||||
/// <summary>The C# defaults (the as-bound shape when the section is absent) pass validation.</summary>
|
||||
[Fact]
|
||||
public void Default_options_succeed()
|
||||
{
|
||||
Sut.Validate(null, new OpcUaApplicationHostOptions()).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A port of 0 reports the shared port-range failure with the OpcUa prefix.</summary>
|
||||
[Fact]
|
||||
public void Zero_port_fails()
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions { OpcUaPort = 0 });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("OpcUa:OpcUaPort must be between 1 and 65535 (was 0)");
|
||||
}
|
||||
|
||||
/// <summary>A blank public hostname reports the shared required failure with the OpcUa prefix.</summary>
|
||||
[Fact]
|
||||
public void Blank_public_hostname_fails()
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions { PublicHostname = "" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("OpcUa:PublicHostname is required");
|
||||
}
|
||||
|
||||
/// <summary>An empty security-profile list reports the shared min-count failure with the OpcUa prefix.</summary>
|
||||
[Fact]
|
||||
public void Empty_security_profiles_fails()
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
||||
{
|
||||
EnabledSecurityProfiles = new List<OpcUaSecurityProfile>(),
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("OpcUa:EnabledSecurityProfiles must contain at least 1 item(s) (had 0)");
|
||||
}
|
||||
}
|
||||
@@ -178,14 +178,16 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
|
||||
if (harness.Mode.UseRealLdap)
|
||||
{
|
||||
configOverrides["Authentication:Ldap:Enabled"] = "true";
|
||||
configOverrides["Authentication:Ldap:Server"] = "localhost";
|
||||
configOverrides["Authentication:Ldap:Port"] = "3894";
|
||||
configOverrides["Authentication:Ldap:UseTls"] = "false";
|
||||
configOverrides["Authentication:Ldap:AllowInsecureLdap"] = "true";
|
||||
configOverrides["Authentication:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
|
||||
configOverrides["Authentication:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
|
||||
configOverrides["Authentication:Ldap:ServiceAccountPassword"] = "ldapadmin";
|
||||
// Bound section is Security:Ldap (see LdapOptions.SectionName); Transport replaces the
|
||||
// old UseTls bool and AllowInsecure replaces AllowInsecureLdap (Task 1.4).
|
||||
configOverrides["Security:Ldap:Enabled"] = "true";
|
||||
configOverrides["Security:Ldap:Server"] = "localhost";
|
||||
configOverrides["Security:Ldap:Port"] = "3894";
|
||||
configOverrides["Security:Ldap:Transport"] = "None";
|
||||
configOverrides["Security:Ldap:AllowInsecure"] = "true";
|
||||
configOverrides["Security:Ldap:SearchBase"] = "dc=zb,dc=local";
|
||||
configOverrides["Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local";
|
||||
configOverrides["Security:Ldap:ServiceAccountPassword"] = "ldapadmin";
|
||||
}
|
||||
|
||||
builder.Configuration.AddInMemoryCollection(configOverrides);
|
||||
@@ -311,8 +313,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
Success: password == "valid-password",
|
||||
DisplayName: username,
|
||||
Username: username,
|
||||
Groups: ["FleetAdmin"],
|
||||
Roles: ["FleetAdmin"],
|
||||
Groups: ["Administrator"],
|
||||
Roles: ["Administrator"],
|
||||
Error: null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
# alice/bob match the GLAuth fixtures so AuthEndpoints contract tests share creds.
|
||||
image: bitnami/openldap:2.6
|
||||
environment:
|
||||
LDAP_ROOT: "dc=lmxopcua,dc=local"
|
||||
LDAP_ROOT: "dc=zb,dc=local"
|
||||
LDAP_ADMIN_USERNAME: "admin"
|
||||
LDAP_ADMIN_PASSWORD: "ldapadmin"
|
||||
LDAP_USERS: "alice,bob"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AuditActor"/> — the static resolution helper that sources the
|
||||
/// <c>Actor</c> field of a canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> from the current
|
||||
/// HTTP principal and falls back to a configurable value when no principal is available.
|
||||
/// </summary>
|
||||
public sealed class AuditActorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="AuditActor.Resolve(IAuditActorAccessor?)"/> returns the accessor's value
|
||||
/// when the accessor returns a non-null string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_returns_accessor_value_when_present()
|
||||
{
|
||||
var accessor = new StubAccessor("alice");
|
||||
|
||||
AuditActor.Resolve(accessor).ShouldBe("alice");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AuditActor.Resolve(IAuditActorAccessor?)"/> returns
|
||||
/// <see cref="AuditActor.SystemFallback"/> when the accessor returns null
|
||||
/// (unauthenticated / no HTTP context).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_returns_system_fallback_when_accessor_returns_null()
|
||||
{
|
||||
var accessor = new StubAccessor(null);
|
||||
|
||||
AuditActor.Resolve(accessor).ShouldBe(AuditActor.SystemFallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AuditActor.Resolve(IAuditActorAccessor?)"/> returns
|
||||
/// <see cref="AuditActor.SystemFallback"/> when the accessor reference itself is null
|
||||
/// (e.g. in a background/non-HTTP context where DI did not inject the accessor).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_returns_system_fallback_when_accessor_is_null()
|
||||
{
|
||||
AuditActor.Resolve(null).ShouldBe(AuditActor.SystemFallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AuditActor.Resolve(IAuditActorAccessor?,string)"/> uses the explicit
|
||||
/// fallback string rather than <see cref="AuditActor.SystemFallback"/> when the accessor
|
||||
/// returns null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_uses_explicit_fallback_when_accessor_returns_null()
|
||||
{
|
||||
var accessor = new StubAccessor(null);
|
||||
|
||||
AuditActor.Resolve(accessor, "scheduler").ShouldBe("scheduler");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AuditActor.Resolve(IAuditActorAccessor?,string)"/> prefers the accessor's
|
||||
/// value over the explicit fallback when the accessor returns a non-null string.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HttpAuditActorAccessor"/>.
|
||||
/// <para>
|
||||
/// Covers the three cases:
|
||||
/// <list type="bullet">
|
||||
/// <item>Authenticated principal with a <see cref="ZbClaimTypes.Username"/> claim →
|
||||
/// returns the username claim value.</item>
|
||||
/// <item>Authenticated principal with only a <see cref="ZbClaimTypes.Name"/> / no
|
||||
/// username claim → falls back to the Name claim.</item>
|
||||
/// <item>No HTTP context (null) or unauthenticated principal → returns
|
||||
/// <see langword="null"/>.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// An authenticated principal that carries <see cref="ZbClaimTypes.Username"/>
|
||||
/// returns exactly that claim value — it is the canonical actor string.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the principal has no <see cref="ZbClaimTypes.Username"/> claim but does have
|
||||
/// a <see cref="ZbClaimTypes.Name"/> claim, the Name claim value is returned as the
|
||||
/// fallback actor.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unauthenticated principal (Identity.IsAuthenticated == false) returns null —
|
||||
/// the caller's fallback (typically <see cref="AuditActor.SystemFallback"/>) is used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Returns_null_for_unauthenticated_principal()
|
||||
{
|
||||
var sut = new HttpAuditActorAccessor(ContextWith(Unauthenticated()));
|
||||
|
||||
sut.CurrentActor.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When there is no current <c>HttpContext</c> (e.g. background task, actor mailbox
|
||||
/// worker), returns null.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -11,11 +13,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
@@ -59,6 +63,11 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
["Security:Jwt:SigningKey"] = "test-signing-key-with-at-least-32-bytes-of-utf8-content",
|
||||
["Security:Jwt:Issuer"] = "otopcua-test",
|
||||
["Security:Jwt:Audience"] = "otopcua-test",
|
||||
// GroupToRole baseline bound onto LdapOptions: the production
|
||||
// OtOpcUaGroupRoleMapper resolves "Viewer" from the LDAP group
|
||||
// "ReadOnly". This exercises the real mapper path — the stub no longer
|
||||
// pre-populates roles, so Viewer can only come from the mapper.
|
||||
["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
|
||||
}).Build();
|
||||
services.AddOtOpcUaAuth(configuration);
|
||||
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
|
||||
@@ -78,6 +87,15 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
// Protected root used by AuthChallengeTests below — exercises the cookie
|
||||
// scheme's challenge heuristic without depending on the full Razor host.
|
||||
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||
// Canonical-claims probe: returns all claim types+values from the cookie
|
||||
// principal so tests can assert the canonical ZbClaimTypes vocabulary.
|
||||
e.MapGet("/auth/whoami", (HttpContext ctx) =>
|
||||
{
|
||||
var claims = ctx.User.Claims
|
||||
.Select(c => new { c.Type, c.Value })
|
||||
.ToArray();
|
||||
return Results.Ok(claims);
|
||||
}).RequireAuthorization();
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -187,13 +205,14 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task Login_merges_db_role_grant_into_claims()
|
||||
{
|
||||
// StubLdapAuthService returns Groups ["ReadOnly"], baseline Roles ["ConfigViewer"].
|
||||
// A system-wide row maps "ReadOnly" → FleetAdmin, so the merged set is both.
|
||||
// StubLdapAuthService returns Groups ["ReadOnly"] with empty Roles (the real production
|
||||
// shape). The mapper resolves the appsettings baseline "ReadOnly" → Viewer, then a
|
||||
// system-wide DB row maps "ReadOnly" → Administrator, so the merged set is both.
|
||||
_roleMappings.Rows.Add(new LdapGroupRoleMapping
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LdapGroup = "ReadOnly",
|
||||
Role = AdminRole.FleetAdmin,
|
||||
Role = AdminRole.Administrator,
|
||||
IsSystemWide = true,
|
||||
ClusterId = null,
|
||||
});
|
||||
@@ -210,22 +229,28 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
|
||||
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
|
||||
roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved
|
||||
roles.ShouldContain("FleetAdmin"); // DB grant merged in
|
||||
roles.ShouldContain("Viewer"); // appsettings baseline preserved
|
||||
roles.ShouldContain("Administrator"); // DB grant merged in
|
||||
}
|
||||
|
||||
/// <summary>When the DB role-map lookup throws, sign-in still succeeds with the appsettings
|
||||
/// baseline roles — a DB hiccup must never block login.</summary>
|
||||
/// <summary>Fail-closed (review I3): when the role mapper throws on the real production path
|
||||
/// (the auth result carries no pre-resolved roles — roles come only from the mapper), sign-in
|
||||
/// still SUCCEEDS but the user is granted ZERO role claims. They are authenticated (can prove
|
||||
/// identity) yet authorized for nothing role-gated until the mapper recovers — the safe
|
||||
/// fail-closed behaviour, not a fail-open with a stale role set.</summary>
|
||||
[Fact]
|
||||
public async Task Login_when_db_role_map_throws_falls_back_to_baseline_roles()
|
||||
public async Task Login_when_role_mapper_throws_signs_in_with_no_role_claims()
|
||||
{
|
||||
// Simulate a mapper fault on the real path. The whole MapAsync throws (the appsettings
|
||||
// baseline is computed inside the mapper, so it does NOT survive the throw): the login
|
||||
// endpoint falls back to result.Roles, which is empty on the real LDAP path.
|
||||
_roleMappings.Throws = true;
|
||||
|
||||
var client = NewClient();
|
||||
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
|
||||
// Login proceeds despite the simulated DB outage.
|
||||
// Login proceeds despite the simulated DB outage — authenticated.
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
@@ -233,9 +258,24 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
||||
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// No role claims at all — fail closed.
|
||||
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
|
||||
roles.ShouldContain("ConfigViewer"); // baseline still present
|
||||
roles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Parses the payload segment of a JWT and returns it as a <see cref="JsonElement"/>.</summary>
|
||||
private static JsonElement JwtPayloadJson(string jwt)
|
||||
{
|
||||
var payloadSegment = jwt.Split('.')[1];
|
||||
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
|
||||
padded = (padded.Length % 4) switch
|
||||
{
|
||||
2 => padded + "==",
|
||||
3 => padded + "=",
|
||||
_ => padded,
|
||||
};
|
||||
return JsonDocument.Parse(Convert.FromBase64String(padded)).RootElement;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the "Role" claim values from a JWT's payload segment.</summary>
|
||||
@@ -255,6 +295,130 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
: [roleProp.GetString()!];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.5 — canonical claims contract: after a successful cookie login the authenticated
|
||||
/// principal MUST carry the canonical ZbClaimTypes vocabulary:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ZbClaimTypes.Name"/> (= ClaimTypes.Name) so Identity.Name resolves.</item>
|
||||
/// <item><see cref="ZbClaimTypes.Username"/> (= "zb:username") — login username.</item>
|
||||
/// <item><see cref="ZbClaimTypes.DisplayName"/> (= "zb:displayname") — human-friendly name.</item>
|
||||
/// <item><see cref="ZbClaimTypes.Role"/> (= ClaimTypes.Role) — at least one role claim.</item>
|
||||
/// </list>
|
||||
/// Also asserts that the old short-name literals "Username" and "DisplayName" are NOT emitted
|
||||
/// (the pre-Task-1.5 strings that would indicate the migration was incomplete).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Login_emits_canonical_ZbClaimTypes_on_cookie_principal()
|
||||
{
|
||||
// Arrange — seed a DB role so the mapper produces a role claim.
|
||||
_roleMappings.Rows.Add(new LdapGroupRoleMapping
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LdapGroup = "ReadOnly",
|
||||
Role = AdminRole.Administrator,
|
||||
IsSystemWide = true,
|
||||
ClusterId = null,
|
||||
});
|
||||
|
||||
var client = NewClient();
|
||||
|
||||
// Act — login.
|
||||
var loginResp = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
loginResp.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
// Call the whoami probe to read back the cookie principal's claims.
|
||||
var whoamiReq = new HttpRequestMessage(HttpMethod.Get, "/auth/whoami");
|
||||
AttachCookies(whoamiReq, loginResp);
|
||||
var whoamiResp = await client.SendAsync(whoamiReq, Ct);
|
||||
whoamiResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var claims = (await whoamiResp.Content.ReadFromJsonAsync<ClaimDto[]>(Ct))!;
|
||||
|
||||
// Assert — canonical name claim (ClaimTypes.Name URI) so Identity.Name resolves.
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.Name && c.Value == "alice",
|
||||
$"Expected {ZbClaimTypes.Name} claim with value 'alice'");
|
||||
|
||||
// Assert — canonical username claim ("zb:username").
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.Username && c.Value == "alice",
|
||||
$"Expected {ZbClaimTypes.Username} claim with value 'alice'");
|
||||
|
||||
// Assert — canonical display-name claim ("zb:displayname").
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.DisplayName && c.Value == "Alice User",
|
||||
$"Expected {ZbClaimTypes.DisplayName} claim with value 'Alice User'");
|
||||
|
||||
// Assert — at least one role claim using canonical ZbClaimTypes.Role (= ClaimTypes.Role).
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.Role,
|
||||
$"Expected at least one {ZbClaimTypes.Role} claim");
|
||||
|
||||
// Assert — old pre-Task-1.5 short literals must NOT appear.
|
||||
claims.ShouldNotContain(c => c.Type == "Username",
|
||||
"Old 'Username' literal must not be emitted after Task 1.5 migration");
|
||||
claims.ShouldNotContain(c => c.Type == "DisplayName",
|
||||
"Old 'DisplayName' literal must not be emitted after Task 1.5 migration");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.5 — JWT payload uses canonical claim keys: after login and token issue the JWT
|
||||
/// payload segment MUST contain "zb:username" and "zb:displayname" keys (not the old short
|
||||
/// "Username"/"DisplayName" strings), AND the role claim(s) MUST be carried under the key
|
||||
/// <see cref="JwtTokenService.RoleClaimType"/> (currently the short "Role" key — intentionally
|
||||
/// NOT the long ClaimTypes.Role URI, because OtOpcUa is JWT-issued-only; see
|
||||
/// <see cref="JwtTokenService.RoleClaimType"/> docs for the rationale and the caveat that
|
||||
/// applies if a JwtBearer scheme is ever added).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Token_payload_uses_canonical_zb_claim_keys()
|
||||
{
|
||||
// Arrange — the appsettings baseline maps group "ReadOnly" → role "Viewer", so alice
|
||||
// (whose groups are ["ReadOnly"]) will carry at least one role in the issued JWT.
|
||||
// No extra DB rows needed — the appsettings GroupToRole entry is always active.
|
||||
var client = NewClient();
|
||||
|
||||
var loginResp = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
loginResp.EnsureSuccessStatusCode();
|
||||
|
||||
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
AttachCookies(tokenReq, loginResp);
|
||||
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
||||
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||
var jwt = payload.GetProperty("token").GetString()!;
|
||||
|
||||
var payloadJson = JwtPayloadJson(jwt);
|
||||
|
||||
// Canonical "zb:username" key must be present.
|
||||
payloadJson.TryGetProperty("zb:username", out var usernameEl).ShouldBeTrue(
|
||||
"JWT payload must carry 'zb:username' claim (canonical ZbClaimTypes.Username)");
|
||||
usernameEl.GetString().ShouldBe("alice");
|
||||
|
||||
// Canonical "zb:displayname" key must be present.
|
||||
payloadJson.TryGetProperty("zb:displayname", out var displayNameEl).ShouldBeTrue(
|
||||
"JWT payload must carry 'zb:displayname' claim (canonical ZbClaimTypes.DisplayName)");
|
||||
displayNameEl.GetString().ShouldBe("Alice User");
|
||||
|
||||
// Role claim(s) must be carried under JwtTokenService.RoleClaimType (= "Role").
|
||||
// This pins the role-key contract: any future rename of RoleClaimType will be caught here.
|
||||
// The appsettings "ReadOnly" → "Viewer" mapping guarantees alice has ≥1 role.
|
||||
payloadJson.TryGetProperty(JwtTokenService.RoleClaimType, out var roleEl).ShouldBeTrue(
|
||||
$"JWT payload must carry at least one role under JwtTokenService.RoleClaimType " +
|
||||
$"(\"{JwtTokenService.RoleClaimType}\")");
|
||||
// The role value may be a string (single) or array (multiple); either way it must be non-empty.
|
||||
if (roleEl.ValueKind == JsonValueKind.Array)
|
||||
roleEl.EnumerateArray().Select(e => e.GetString()).ShouldNotBeEmpty(
|
||||
"JWT role array must contain at least one role value");
|
||||
else
|
||||
roleEl.GetString().ShouldNotBeNullOrEmpty("JWT role value must not be empty");
|
||||
|
||||
// Old short-name literals must NOT be present.
|
||||
payloadJson.TryGetProperty("Username", out _).ShouldBeFalse(
|
||||
"JWT payload must not carry old 'Username' key after Task 1.5 migration");
|
||||
payloadJson.TryGetProperty("DisplayName", out _).ShouldBeFalse(
|
||||
"JWT payload must not carry old 'DisplayName' key after Task 1.5 migration");
|
||||
}
|
||||
|
||||
/// <summary>Tests that logout clears the cookie.</summary>
|
||||
[Fact]
|
||||
public async Task Logout_clears_the_cookie()
|
||||
@@ -330,7 +494,11 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
DisplayName: "Alice User",
|
||||
Username: username,
|
||||
Groups: ["ReadOnly"],
|
||||
Roles: ["ConfigViewer"],
|
||||
// Roles empty — the real production path returns groups, never roles. Role
|
||||
// resolution is the mapper's job (OtOpcUaGroupRoleMapper applies the
|
||||
// GroupToRole baseline). This proves roles flow through the mapper, not via
|
||||
// pre-population of the auth result.
|
||||
Roles: [],
|
||||
Error: null));
|
||||
return Task.FromResult(new LdapAuthResult(
|
||||
Success: false,
|
||||
@@ -375,4 +543,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for deserialising the /auth/whoami claim list.
|
||||
/// Must match the anonymous projection in the whoami endpoint.
|
||||
/// </summary>
|
||||
private sealed record ClaimDto(string Type, string Value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.7 — control-plane admin roles are standardized on the canonical six
|
||||
/// (<c>Viewer / Operator / Engineer / Designer / Deployer / Administrator</c>). OtOpcUa
|
||||
/// uses four of them: ConfigViewer→Viewer, ConfigEditor→Designer, FleetAdmin→Administrator,
|
||||
/// and the appsettings-only DriverOperator→Operator. These tests pin the canonical role
|
||||
/// VALUES end-to-end (mapper output claims + the real registered authorization policies) and
|
||||
/// prove enforcement semantics are preserved (whoever could deploy/administer/operate before
|
||||
/// still can — it is a rename, not a permission change).
|
||||
/// </summary>
|
||||
public sealed class CanonicalAdminRolesTests
|
||||
{
|
||||
// --- (a) the mapper mints the CANONICAL role claim for each native group ----------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("Viewer")] // was ConfigViewer
|
||||
[InlineData("Designer")] // was ConfigEditor
|
||||
[InlineData("Administrator")] // was FleetAdmin
|
||||
[InlineData("Operator")] // was DriverOperator (appsettings-only string role)
|
||||
public async Task Mapper_yields_canonical_role_for_native_group(string canonicalRole)
|
||||
{
|
||||
// appsettings GroupToRole baseline carries the canonical value verbatim.
|
||||
var mapper = BuildMapper(new Dictionary<string, string> { ["the-group"] = canonicalRole });
|
||||
|
||||
var result = await mapper.MapAsync(["the-group"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldContain(canonicalRole);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AdminRole.Viewer, "Viewer")]
|
||||
[InlineData(AdminRole.Designer, "Designer")]
|
||||
[InlineData(AdminRole.Administrator, "Administrator")]
|
||||
public async Task System_wide_db_row_role_renders_as_canonical_string(AdminRole role, string expected)
|
||||
{
|
||||
// The DB path stringifies the enum member name (row.Role.ToString()); renaming the enum
|
||||
// members is what makes the persisted/emitted string canonical.
|
||||
var mapper = BuildMapper(
|
||||
new Dictionary<string, string>(),
|
||||
new LdapGroupRoleMapping { LdapGroup = "g", Role = role, IsSystemWide = true });
|
||||
|
||||
var result = await mapper.MapAsync(["g"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldContain(expected);
|
||||
}
|
||||
|
||||
// --- (b)/(c) the REAL registered authorization policies enforce on the canonical values ---
|
||||
|
||||
[Fact]
|
||||
public async Task Deployments_role_check_authorizes_Designer_and_Administrator()
|
||||
{
|
||||
// Deployments.razor uses [Authorize(Roles="Administrator,Designer")] — a direct role-string
|
||||
// check (not a named policy). Reproduce it via RequireRole and prove both still pass.
|
||||
var policy = new AuthorizationPolicyBuilder()
|
||||
.RequireRole("Administrator", "Designer")
|
||||
.Build();
|
||||
var authz = BuildAuthorizationService();
|
||||
|
||||
(await authz.AuthorizeAsync(UserInRole("Designer"), policy)).Succeeded.ShouldBeTrue();
|
||||
(await authz.AuthorizeAsync(UserInRole("Administrator"), policy)).Succeeded.ShouldBeTrue();
|
||||
(await authz.AuthorizeAsync(UserInRole("Viewer"), policy)).Succeeded.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FleetAdmin_policy_authorizes_only_Administrator()
|
||||
{
|
||||
var authz = BuildAuthorizationService();
|
||||
|
||||
// RoleGrants.razor is gated by the "FleetAdmin" named policy → RequireRole("Administrator").
|
||||
(await authz.AuthorizeAsync(UserInRole("Administrator"), "FleetAdmin")).Succeeded.ShouldBeTrue();
|
||||
(await authz.AuthorizeAsync(UserInRole("Designer"), "FleetAdmin")).Succeeded.ShouldBeFalse();
|
||||
(await authz.AuthorizeAsync(UserInRole("Operator"), "FleetAdmin")).Succeeded.ShouldBeFalse();
|
||||
(await authz.AuthorizeAsync(UserInRole("Viewer"), "FleetAdmin")).Succeeded.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DriverOperator_policy_authorizes_Operator_and_Administrator()
|
||||
{
|
||||
var authz = BuildAuthorizationService();
|
||||
|
||||
// DriverStatusPanel/pickers gate on the "DriverOperator" named policy →
|
||||
// RequireRole("Operator","Administrator"). Operator (was DriverOperator) and Administrator
|
||||
// (was FleetAdmin) both pass; a plain Viewer does not.
|
||||
(await authz.AuthorizeAsync(UserInRole("Operator"), "DriverOperator")).Succeeded.ShouldBeTrue();
|
||||
(await authz.AuthorizeAsync(UserInRole("Administrator"), "DriverOperator")).Succeeded.ShouldBeTrue();
|
||||
(await authz.AuthorizeAsync(UserInRole("Viewer"), "DriverOperator")).Succeeded.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------------------
|
||||
|
||||
private static ClaimsPrincipal UserInRole(string role)
|
||||
{
|
||||
// ZbClaimTypes.Role aliases ClaimTypes.Role, the default role-claim type, so RequireRole /
|
||||
// IsInRole resolve against it.
|
||||
var identity = new ClaimsIdentity(
|
||||
[new Claim(ZbClaimTypes.Role, role)], authenticationType: "Test");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
private static IAuthorizationService BuildAuthorizationService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
// Use the REAL policy registrations from AddOtOpcUaAuth; it needs the ConfigDbContext for
|
||||
// DataProtection key persistence, so register an in-memory one.
|
||||
services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("authz-test"));
|
||||
services.AddOtOpcUaAuth(new ConfigurationBuilder().Build());
|
||||
|
||||
return services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
|
||||
}
|
||||
|
||||
private static OtOpcUaGroupRoleMapper BuildMapper(
|
||||
IDictionary<string, string> groupToRole,
|
||||
params LdapGroupRoleMapping[] dbRows)
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions
|
||||
{
|
||||
GroupToRole = new Dictionary<string, string>(groupToRole, StringComparer.OrdinalIgnoreCase),
|
||||
});
|
||||
return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows));
|
||||
}
|
||||
|
||||
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
|
||||
{
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(rows);
|
||||
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(rows);
|
||||
|
||||
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
public sealed class LdapHelperTests
|
||||
{
|
||||
/// <summary>Verifies that LDAP filter special characters are properly escaped.</summary>
|
||||
/// <param name="input">The input string.</param>
|
||||
/// <param name="expected">The expected escaped output.</param>
|
||||
[Theory]
|
||||
[InlineData("joe", "joe")]
|
||||
[InlineData("jo*e", "jo\\2ae")]
|
||||
[InlineData("jo(e", "jo\\28e")]
|
||||
[InlineData("jo)e", "jo\\29e")]
|
||||
[InlineData("jo\\e", "jo\\5ce")]
|
||||
public void EscapeLdapFilter_escapes_special_chars(string input, string expected)
|
||||
{
|
||||
LdapAuthService.EscapeLdapFilter(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the first organizational unit segment is correctly extracted from a DN.</summary>
|
||||
/// <param name="dn">The distinguished name.</param>
|
||||
/// <param name="expected">The expected organizational unit value.</param>
|
||||
[Theory]
|
||||
[InlineData("cn=joe,ou=Admins,dc=lmxopcua,dc=local", "Admins")]
|
||||
[InlineData("cn=alice,dc=lmxopcua,dc=local", null)]
|
||||
[InlineData("ou=Admins,dc=lmxopcua,dc=local", "Admins")]
|
||||
public void ExtractOuSegment_returns_first_ou(string dn, string? expected)
|
||||
{
|
||||
LdapAuthService.ExtractOuSegment(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the first RDN value is correctly extracted from various DN formats.</summary>
|
||||
/// <param name="dn">The distinguished name.</param>
|
||||
/// <param name="expected">The expected RDN value.</param>
|
||||
[Theory]
|
||||
[InlineData("cn=Admins,dc=lmxopcua,dc=local", "Admins")]
|
||||
[InlineData("cn=Admins", "Admins")]
|
||||
[InlineData("Admins", "Admins")]
|
||||
public void ExtractFirstRdnValue_handles_full_and_short_dns(string dn, string expected)
|
||||
{
|
||||
LdapAuthService.ExtractFirstRdnValue(dn).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves <see cref="OtOpcUaGroupRoleMapper"/> is a behaviour-preserving wrapper over the
|
||||
/// existing <see cref="RoleMapper.Map"/> + <see cref="RoleMapper.Merge"/> logic: config
|
||||
/// baseline + system-wide DB grants, cluster-scoped DB rows ignored, unmapped groups dropped,
|
||||
/// and <c>Scope</c> always null.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaGroupRoleMapperTests
|
||||
{
|
||||
private static OtOpcUaGroupRoleMapper Build(
|
||||
IDictionary<string, string> groupToRole,
|
||||
params LdapGroupRoleMapping[] dbRows)
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions
|
||||
{
|
||||
GroupToRole = new Dictionary<string, string>(groupToRole, StringComparer.OrdinalIgnoreCase),
|
||||
});
|
||||
return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_config_group_and_drops_unmapped_group()
|
||||
{
|
||||
var mapper = Build(new Dictionary<string, string> { ["AdminGroup"] = "Administrator" });
|
||||
|
||||
var result = await mapper.MapAsync(["AdminGroup", "UnmappedGroup"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldBe(["Administrator"]);
|
||||
result.Scope.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_wide_db_row_adds_role_on_top_of_config_baseline()
|
||||
{
|
||||
var mapper = Build(
|
||||
new Dictionary<string, string> { ["viewers"] = "Viewer" },
|
||||
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true });
|
||||
|
||||
var result = await mapper.MapAsync(["viewers", "admins"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldContain("Viewer");
|
||||
result.Roles.ShouldContain("Administrator");
|
||||
result.Scope.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cluster_scoped_db_row_is_ignored()
|
||||
{
|
||||
var mapper = Build(
|
||||
new Dictionary<string, string>(),
|
||||
new LdapGroupRoleMapping
|
||||
{
|
||||
LdapGroup = "site-a-editors",
|
||||
Role = AdminRole.Designer,
|
||||
IsSystemWide = false,
|
||||
ClusterId = "SITE-A",
|
||||
});
|
||||
|
||||
var result = await mapper.MapAsync(["site-a-editors"], CancellationToken.None);
|
||||
|
||||
result.Roles.ShouldNotContain("Designer");
|
||||
result.Roles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs()
|
||||
{
|
||||
var groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["viewers"] = "Viewer",
|
||||
["editors"] = "Designer",
|
||||
};
|
||||
var dbRows = new[]
|
||||
{
|
||||
new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true },
|
||||
new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||
};
|
||||
var groups = new[] { "viewers", "editors", "admins", "site-a", "noise" };
|
||||
|
||||
var mapper = Build(groupToRole, dbRows);
|
||||
|
||||
// Oracle: exactly what the legacy login path computes today.
|
||||
var baseline = RoleMapper.Map(groups, groupToRole);
|
||||
var expected = RoleMapper.Merge(baseline, dbRows);
|
||||
|
||||
var result = await mapper.MapAsync(groups, CancellationToken.None);
|
||||
|
||||
result.Roles.OrderBy(r => r).ShouldBe(expected.OrderBy(r => r));
|
||||
result.Scope.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>In-memory stand-in for the EF-backed DB service; returns the configured rows verbatim.</summary>
|
||||
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
|
||||
{
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(rows);
|
||||
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(rows);
|
||||
|
||||
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
using LdapAuthFailure = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthFailure;
|
||||
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
|
||||
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.2 — proves <see cref="OtOpcUaLdapAuthService"/> (the app's ILdapAuthService wrapper over
|
||||
/// the shared <c>ZB.MOM.WW.Auth.Ldap</c> service) preserves the two app-only concerns the library
|
||||
/// does not model: the <c>Enabled</c> master switch and the <c>DevStubMode</c> bypass. Both must
|
||||
/// short-circuit WITHOUT delegating to the library. On the real path it adapts the library result
|
||||
/// (groups, never roles) onto the app result shape with roles left for the downstream mapper.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaLdapAuthServiceTests
|
||||
{
|
||||
private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) =>
|
||||
new(options, inner, NullLogger<OtOpcUaLdapAuthService>.Instance);
|
||||
|
||||
/// <summary>DevStubMode on → stub Administrator success WITHOUT hitting the library.</summary>
|
||||
[Fact]
|
||||
public async Task DevStubMode_grants_Administrator_without_calling_the_library()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("anyone", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Username.ShouldBe("anyone");
|
||||
result.Groups.ShouldBe(new[] { "dev" });
|
||||
result.Roles.ShouldBe(new[] { "Administrator" });
|
||||
inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client");
|
||||
}
|
||||
|
||||
/// <summary>Enabled=false → denial, no library call (master switch wins over DevStubMode).</summary>
|
||||
[Fact]
|
||||
public async Task Disabled_denies_without_calling_the_library_even_with_devstub()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||
var sut = Build(new LdapOptions { Enabled = false, DevStubMode = true }, inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("user", "pw", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe("LDAP authentication is disabled.");
|
||||
inner.Called.ShouldBeFalse("a disabled provider must never touch the network");
|
||||
}
|
||||
|
||||
/// <summary>Real path: a library success surfaces its Groups; Roles are left empty for the
|
||||
/// downstream mapper (the library returns groups, not roles).</summary>
|
||||
[Fact]
|
||||
public async Task Real_path_success_surfaces_groups_and_leaves_roles_for_the_mapper()
|
||||
{
|
||||
var inner = new RecordingLibService(
|
||||
LibLdapAuthResult.Success("alice", "Alice User", new[] { "ReadOnly", "Engineers" }));
|
||||
var sut = Build(
|
||||
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
|
||||
inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
inner.Called.ShouldBeTrue();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Username.ShouldBe("alice");
|
||||
result.DisplayName.ShouldBe("Alice User");
|
||||
result.Groups.ShouldBe(new[] { "ReadOnly", "Engineers" });
|
||||
result.Roles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Real path: a library failure folds into a fail-closed error string.</summary>
|
||||
[Fact]
|
||||
public async Task Real_path_failure_folds_into_error()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
var sut = Build(
|
||||
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
|
||||
inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("alice", "wrong", CancellationToken.None);
|
||||
|
||||
inner.Called.ShouldBeTrue();
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Invalid username or password");
|
||||
}
|
||||
|
||||
/// <summary>Insecure transport without AllowInsecure fails closed at the auth boundary WITHOUT
|
||||
/// reaching the library — preserving the bespoke service's login-time guard after UseTls→Transport.</summary>
|
||||
[Fact]
|
||||
public async Task Insecure_transport_without_AllowInsecure_fails_closed_without_calling_library()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||
var sut = Build(
|
||||
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.None, AllowInsecure = false },
|
||||
inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error!.ShouldContain("Insecure LDAP is disabled");
|
||||
inner.Called.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Empty username/password are rejected up front without a library call.</summary>
|
||||
[Theory]
|
||||
[InlineData("", "pw")]
|
||||
[InlineData("user", "")]
|
||||
public async Task Empty_credentials_are_rejected_without_calling_library(string user, string pw)
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||
var sut = Build(new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps }, inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync(user, pw, CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
inner.Called.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
|
||||
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
|
||||
{
|
||||
public bool Called { get; private set; }
|
||||
|
||||
public Task<LibLdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
Called = true;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ public sealed class RoleMapperTests
|
||||
{
|
||||
RoleMapper.Map(
|
||||
new[] { "AdminGroup" },
|
||||
new Dictionary<string, string> { ["AdminGroup"] = "FleetAdmin" })
|
||||
.ShouldBe(new[] { "FleetAdmin" });
|
||||
new Dictionary<string, string> { ["AdminGroup"] = "Administrator" })
|
||||
.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -40,9 +40,9 @@ public sealed class RoleMapperTests
|
||||
new[] { "admingroup" },
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["AdminGroup"] = "FleetAdmin",
|
||||
["AdminGroup"] = "Administrator",
|
||||
})
|
||||
.ShouldBe(new[] { "FleetAdmin" });
|
||||
.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -55,11 +55,11 @@ public sealed class RoleMapperTests
|
||||
new[] { "AdminGroup", "AlsoAdmin" },
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["AdminGroup"] = "FleetAdmin",
|
||||
["AlsoAdmin"] = "FleetAdmin",
|
||||
["AdminGroup"] = "Administrator",
|
||||
["AlsoAdmin"] = "Administrator",
|
||||
});
|
||||
|
||||
roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
roles.ShouldBe(new[] { "Administrator" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -67,16 +67,16 @@ public sealed class RoleMapperTests
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true },
|
||||
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||
new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.Administrator, IsSystemWide = true },
|
||||
new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" },
|
||||
};
|
||||
var result = RoleMapper.Merge(["ConfigViewer"], rows);
|
||||
result.ShouldContain("ConfigViewer");
|
||||
result.ShouldContain("FleetAdmin");
|
||||
result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only)
|
||||
var result = RoleMapper.Merge(["Viewer"], rows);
|
||||
result.ShouldContain("Viewer");
|
||||
result.ShouldContain("Administrator");
|
||||
result.ShouldNotContain("Designer"); // cluster-scoped row ignored (global-only)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_with_no_db_rows_returns_baseline()
|
||||
=> RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]);
|
||||
=> RoleMapper.Merge(["Administrator"], []).ShouldBe(["Administrator"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user