Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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" />
|
||||
@@ -99,7 +99,14 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,6 +1,7 @@
|
||||
<?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" />
|
||||
@@ -15,6 +16,12 @@
|
||||
<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" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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>();
|
||||
}
|
||||
|
||||
@@ -135,7 +154,7 @@ if (hasAdmin)
|
||||
}
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
<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