Compare commits
6 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8464e3f376 | ||
| a9357600e7 | |||
|
|
2f00c74bbb | ||
| 5d5e1f9650 | |||
|
|
4886a5783f | ||
| d70a2e0077 |
@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
|
|||||||
|
|
||||||
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
||||||
|
|
||||||
|
### Active Directory configuration
|
||||||
|
|
||||||
|
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"OpcUaServer": {
|
||||||
|
"Ldap": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Server": "dc01.corp.example.com",
|
||||||
|
"Port": 636,
|
||||||
|
"UseTls": true,
|
||||||
|
"AllowInsecureLdap": false,
|
||||||
|
"SearchBase": "DC=corp,DC=example,DC=com",
|
||||||
|
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
||||||
|
"ServiceAccountPassword": "<from your secret store>",
|
||||||
|
"DisplayNameAttribute": "displayName",
|
||||||
|
"GroupAttribute": "memberOf",
|
||||||
|
"UserNameAttribute": "sAMAccountName",
|
||||||
|
"GroupToRole": {
|
||||||
|
"OPCUA-Operators": "WriteOperate",
|
||||||
|
"OPCUA-Engineers": "WriteConfigure",
|
||||||
|
"OPCUA-AlarmAck": "AlarmAck",
|
||||||
|
"OPCUA-Tuners": "WriteTune"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
|
||||||
|
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
|
||||||
|
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
|
||||||
|
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
|
||||||
|
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
|
|
||||||
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
||||||
|
|||||||
@@ -58,18 +58,25 @@ Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
|
|||||||
deployment default. That's a production-hardening config change, not a code
|
deployment default. That's a production-hardening config change, not a code
|
||||||
gap — the Admin UI is now ready to be the trust gate.
|
gap — the Admin UI is now ready to be the trust gate.
|
||||||
|
|
||||||
## 4. Live-LDAP integration test
|
## 4. Live-LDAP integration test — **DONE (PR 31)**
|
||||||
|
|
||||||
**Status**: PR 19 unit-tested the auth-flow shape; the live bind path is
|
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
|
||||||
exercised only by the pre-existing `Admin.Tests/LdapLiveBindTests.cs` which
|
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
|
||||||
uses the same Novell library against a running GLAuth at `localhost:3893`.
|
when the port is unreachable. Covers: valid bind, wrong password, unknown
|
||||||
|
user, empty credentials, single-group → WriteOperate mapping, multi-group
|
||||||
|
admin user surfacing all mapped roles.
|
||||||
|
|
||||||
**To do**:
|
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
|
||||||
- Add `OpcUaServerIntegrationTests.Valid_username_authenticates_against_live_ldap`
|
compat) so Active Directory deployments can configure `sAMAccountName` /
|
||||||
with the same skip-when-unreachable guard.
|
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
|
||||||
- Assert `session.Identity` on the server side carries the expected role
|
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
|
||||||
after bind — requires exposing a test hook or reading identity from a
|
`docs/security.md` §"Active Directory configuration" for the AD appsettings
|
||||||
new `IHostConnectivityProbe`-style "whoami" variable in the address space.
|
snippet.
|
||||||
|
|
||||||
|
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
|
||||||
|
drive a full OPC UA session with username/password, then read an
|
||||||
|
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
|
||||||
|
That needs a test-only address-space node and is a separate PR.
|
||||||
|
|
||||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
||||||
|
|
||||||
@@ -84,18 +91,22 @@ no single end-to-end smoke test.
|
|||||||
subscribes to one of its attributes, writes a value back, and asserts the
|
subscribes to one of its attributes, writes a value back, and asserts the
|
||||||
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
||||||
|
|
||||||
## 6. Second driver instance on the same server
|
## 6. Second driver instance on the same server — **DONE (PR 32)**
|
||||||
|
|
||||||
**Status**: `DriverHost.RegisterAsync` supports multiple drivers; the OPC UA
|
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
|
||||||
server creates one `DriverNodeManager` per driver and isolates their
|
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
|
||||||
subtrees under distinct namespace URIs. Not proven with two active
|
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
|
||||||
`GalaxyProxyDriver` instances pointing at different Galaxies.
|
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
|
||||||
|
NamespaceUris, (2) browsing one subtree returns that driver's folder and
|
||||||
|
does NOT leak the other driver's folder, (3) reads route to the correct
|
||||||
|
driver — the alpha instance returns 42 while beta returns 99, so a misroute
|
||||||
|
would surface at the assertion layer.
|
||||||
|
|
||||||
**To do**:
|
Deferred: the alarm-event multi-driver parity case (two drivers each raising
|
||||||
- Integration test that registers two driver instances, each with a distinct
|
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
|
||||||
`DriverInstanceId` + endpoint in its own session, asserts nodes from both
|
condition node). Alarm tracking already has its own integration test
|
||||||
appear under the correct subtrees, alarm events land on the correct
|
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
|
||||||
instance's condition nodes.
|
`IAlarmSource` that's worth its own focused PR.
|
||||||
|
|
||||||
## 7. Host-status per-AppEngine granularity → Admin UI dashboard
|
## 7. Host-status per-AppEngine granularity → Admin UI dashboard
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-host connectivity snapshot the Server publishes for each driver's
|
||||||
|
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
|
||||||
|
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
|
||||||
|
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
|
||||||
|
/// rows, not 3, because each server node owns its own runtime view.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
|
||||||
|
/// drill-down). The publisher hosted service on the Server side subscribes to every
|
||||||
|
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
|
||||||
|
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
|
||||||
|
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
|
||||||
|
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
|
||||||
|
/// rather keep the status row than drop it. The Admin-side service left-joins on
|
||||||
|
/// NodeId when presenting rows.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverHostStatus
|
||||||
|
{
|
||||||
|
/// <summary>Server node that's running the driver.</summary>
|
||||||
|
public required string NodeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
|
||||||
|
public required string DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
|
||||||
|
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
|
||||||
|
/// a display string.
|
||||||
|
/// </summary>
|
||||||
|
public required string HostName { get; set; }
|
||||||
|
|
||||||
|
public DriverHostState State { get; set; } = DriverHostState.Unknown;
|
||||||
|
|
||||||
|
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
|
||||||
|
public DateTime StateChangedUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances on every publisher heartbeat — the Admin UI uses
|
||||||
|
/// <c>now - LastSeenUtc > threshold</c> to flag rows whose owning Server has
|
||||||
|
/// stopped reporting (crashed, network-partitioned, etc.), independent of
|
||||||
|
/// <see cref="State"/>.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastSeenUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional human-readable detail populated when <see cref="State"/> is
|
||||||
|
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
|
||||||
|
/// driver's probe. Null for Running / Stopped / Unknown transitions.
|
||||||
|
/// </summary>
|
||||||
|
public string? Detail { get; set; }
|
||||||
|
}
|
||||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
|
||||||
|
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
|
||||||
|
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
|
||||||
|
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
|
||||||
|
/// Configuration project stays free of driver-runtime dependencies.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The server-side publisher (follow-up PR) translates
|
||||||
|
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
|
||||||
|
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
|
||||||
|
/// </remarks>
|
||||||
|
public enum DriverHostState
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
Faulted,
|
||||||
|
}
|
||||||
1248
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
1248
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDriverHostStatus : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DriverHostStatus",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||||
|
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||||
|
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DriverHostStatus_LastSeen",
|
||||||
|
table: "DriverHostStatus",
|
||||||
|
column: "LastSeenUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DriverHostStatus_Node",
|
||||||
|
table: "DriverHostStatus",
|
||||||
|
column: "NodeId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DriverHostStatus");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,6 +332,46 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("NodeId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("DriverInstanceId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("HostName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastSeenUtc")
|
||||||
|
.HasColumnType("datetime2(3)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("nvarchar(16)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StateChangedUtc")
|
||||||
|
.HasColumnType("datetime2(3)");
|
||||||
|
|
||||||
|
b.HasKey("NodeId", "DriverInstanceId", "HostName");
|
||||||
|
|
||||||
|
b.HasIndex("LastSeenUtc")
|
||||||
|
.HasDatabaseName("IX_DriverHostStatus_LastSeen");
|
||||||
|
|
||||||
|
b.HasIndex("NodeId")
|
||||||
|
.HasDatabaseName("IX_DriverHostStatus_Node");
|
||||||
|
|
||||||
|
b.ToTable("DriverHostStatus", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
|
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("DriverInstanceRowId")
|
b.Property<Guid>("DriverInstanceRowId")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
||||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||||
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||||
|
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -47,6 +48,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
ConfigureClusterNodeGenerationState(modelBuilder);
|
ConfigureClusterNodeGenerationState(modelBuilder);
|
||||||
ConfigureConfigAuditLog(modelBuilder);
|
ConfigureConfigAuditLog(modelBuilder);
|
||||||
ConfigureExternalIdReservation(modelBuilder);
|
ConfigureExternalIdReservation(modelBuilder);
|
||||||
|
ConfigureDriverHostStatus(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||||
@@ -484,4 +486,30 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
|||||||
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<DriverHostStatus>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("DriverHostStatus");
|
||||||
|
// Composite key — one row per (server node, driver instance, probe-reported host).
|
||||||
|
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
|
||||||
|
// 6 rows because each server node owns its own runtime view; the composite key is
|
||||||
|
// what lets both views coexist without shadowing each other.
|
||||||
|
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||||
|
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.HostName).HasMaxLength(256);
|
||||||
|
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.Detail).HasMaxLength(1024);
|
||||||
|
|
||||||
|
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
|
||||||
|
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
|
||||||
|
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
|
||||||
|
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
|
||||||
|
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,37 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
|
/// LDAP settings for the OPC UA server's UserName token validator. Bound from
|
||||||
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults match the GLAuth dev instance
|
/// <c>appsettings.json</c> <c>OpcUaServer:Ldap</c>. Defaults target the GLAuth dev instance
|
||||||
/// (localhost:3893, dc=lmxopcua,dc=local). Production deployments set <see cref="UseTls"/>
|
/// (localhost:3893, <c>dc=lmxopcua,dc=local</c>) for the stock inner-loop setup. Production
|
||||||
/// true, populate <see cref="ServiceAccountDn"/> for search-then-bind, and maintain
|
/// deployments are expected to point at Active Directory; see <see cref="UserNameAttribute"/>
|
||||||
/// <see cref="GroupToRole"/> with the real LDAP group names.
|
/// and the per-field xml-docs for the AD-specific overrides.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Active Directory cheat-sheet</b>:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="Server"/>: one of the domain controllers, or the domain FQDN (will round-robin DCs).</item>
|
||||||
|
/// <item><see cref="Port"/>: <c>389</c> (LDAP) or <c>636</c> (LDAPS); use 636 + <see cref="UseTls"/> in production.</item>
|
||||||
|
/// <item><see cref="UseTls"/>: <c>true</c>. AD increasingly rejects plain-LDAP bind under LDAP-signing enforcement.</item>
|
||||||
|
/// <item><see cref="AllowInsecureLdap"/>: <c>false</c>. Dev escape hatch only.</item>
|
||||||
|
/// <item><see cref="SearchBase"/>: <c>DC=corp,DC=example,DC=com</c> — your domain's base DN.</item>
|
||||||
|
/// <item><see cref="ServiceAccountDn"/>: a dedicated service principal with read access to user + group entries
|
||||||
|
/// (e.g. <c>CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com</c>). Never a privileged admin.</item>
|
||||||
|
/// <item><see cref="UserNameAttribute"/>: <c>sAMAccountName</c> (classic login name) or <c>userPrincipalName</c>
|
||||||
|
/// (user@domain form). Default is <c>uid</c> which AD does <b>not</b> populate, so this override is required.</item>
|
||||||
|
/// <item><see cref="DisplayNameAttribute"/>: <c>displayName</c> gives the human name; <c>cn</c> works too but is less rich.</item>
|
||||||
|
/// <item><see cref="GroupAttribute"/>: <c>memberOf</c> — matches AD's default. Values are full DNs
|
||||||
|
/// (<c>CN=<Group>,OU=...,DC=...</c>); the authenticator strips the leading <c>CN=</c> RDN value and uses
|
||||||
|
/// that as the lookup key in <see cref="GroupToRole"/>.</item>
|
||||||
|
/// <item><see cref="GroupToRole"/>: maps your AD group common-names to OPC UA roles — e.g.
|
||||||
|
/// <c>{"OPCUA-Operators" : "WriteOperate", "OPCUA-Engineers" : "WriteConfigure"}</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// Nested groups are <b>not</b> expanded — AD's <c>tokenGroups</c> / <c>LDAP_MATCHING_RULE_IN_CHAIN</c>
|
||||||
|
/// membership-chain filter isn't used. Assign users directly to the role-mapped groups, or pre-flatten
|
||||||
|
/// membership in your directory. If nested expansion becomes a requirement, it's an authenticator
|
||||||
|
/// enhancement (not a config change).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
public sealed class LdapOptions
|
public sealed class LdapOptions
|
||||||
{
|
{
|
||||||
public bool Enabled { get; init; } = false;
|
public bool Enabled { get; init; } = false;
|
||||||
@@ -23,6 +49,20 @@ public sealed class LdapOptions
|
|||||||
public string DisplayNameAttribute { get; init; } = "cn";
|
public string DisplayNameAttribute { get; init; } = "cn";
|
||||||
public string GroupAttribute { get; init; } = "memberOf";
|
public string GroupAttribute { get; init; } = "memberOf";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LDAP attribute used to match a login name against user entries in the directory.
|
||||||
|
/// Defaults to <c>uid</c> (RFC 2307). Common overrides:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>sAMAccountName</c> — Active Directory, classic NT-style login names (e.g. <c>jdoe</c>).</item>
|
||||||
|
/// <item><c>userPrincipalName</c> — Active Directory, email-style (e.g. <c>jdoe@corp.example.com</c>).</item>
|
||||||
|
/// <item><c>cn</c> — GLAuth + some OpenLDAP deployments where users are keyed by common-name.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Used only when <see cref="ServiceAccountDn"/> is non-empty (search-then-bind path) —
|
||||||
|
/// direct-bind fallback constructs the DN as <c>cn=<name>,<SearchBase></c>
|
||||||
|
/// regardless of this setting and is not a production-grade path against AD.
|
||||||
|
/// </summary>
|
||||||
|
public string UserNameAttribute { get; init; } = "uid";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
|
/// LDAP group → OPC UA role. Each authenticated user gets every role whose source group
|
||||||
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse
|
/// is in their membership list. Recognized role names (CLAUDE.md): <c>ReadOnly</c> (browse
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public sealed class LdapUserAuthenticator(LdapOptions options, ILogger<LdapUserA
|
|||||||
{
|
{
|
||||||
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
await Task.Run(() => conn.Bind(options.ServiceAccountDn, options.ServiceAccountPassword), ct);
|
||||||
|
|
||||||
var filter = $"(uid={EscapeLdapFilter(username)})";
|
var filter = $"({options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||||
var results = await Task.Run(() =>
|
var results = await Task.Run(() =>
|
||||||
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
conn.Search(options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end round-trip through the DB for the <see cref="DriverHostStatus"/> entity
|
||||||
|
/// added in PR 33 — exercises the composite primary key (NodeId, DriverInstanceId,
|
||||||
|
/// HostName), string-backed <c>DriverHostState</c> conversion, and the two indexes the
|
||||||
|
/// Admin UI's drill-down queries will scan (NodeId, LastSeenUtc).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "SchemaCompliance")]
|
||||||
|
[Collection(nameof(SchemaComplianceCollection))]
|
||||||
|
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
|
||||||
|
{
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
|
||||||
|
// Same HostName + DriverInstanceId across two different server nodes — classic 2-node
|
||||||
|
// redundancy case. Both rows must be insertable because each server node owns its own
|
||||||
|
// runtime view of the shared host.
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||||
|
{
|
||||||
|
NodeId = "node-a", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||||
|
State = DriverHostState.Running,
|
||||||
|
StateChangedUtc = now, LastSeenUtc = now,
|
||||||
|
});
|
||||||
|
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||||
|
{
|
||||||
|
NodeId = "node-b", DriverInstanceId = "galaxy-1", HostName = "GRPlatform",
|
||||||
|
State = DriverHostState.Stopped,
|
||||||
|
StateChangedUtc = now, LastSeenUtc = now,
|
||||||
|
Detail = "secondary hasn't taken over yet",
|
||||||
|
});
|
||||||
|
// Same server node + host, different driver instance — second driver doesn't clobber.
|
||||||
|
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||||
|
{
|
||||||
|
NodeId = "node-a", DriverInstanceId = "modbus-plc1", HostName = "GRPlatform",
|
||||||
|
State = DriverHostState.Running,
|
||||||
|
StateChangedUtc = now, LastSeenUtc = now,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var rows = await ctx.DriverHostStatuses.AsNoTracking()
|
||||||
|
.Where(r => r.HostName == "GRPlatform").ToListAsync();
|
||||||
|
|
||||||
|
rows.Count.ShouldBe(3);
|
||||||
|
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "galaxy-1");
|
||||||
|
rows.ShouldContain(r => r.NodeId == "node-b" && r.State == DriverHostState.Stopped && r.Detail == "secondary hasn't taken over yet");
|
||||||
|
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upsert_pattern_for_same_key_updates_in_place()
|
||||||
|
{
|
||||||
|
// The publisher hosted service (follow-up PR) upserts on every transition +
|
||||||
|
// heartbeat. This test pins the two-step pattern it will use: check-then-add-or-update
|
||||||
|
// keyed on the composite PK. If the composite key ever changes, this test breaks
|
||||||
|
// loudly so the publisher gets a synchronized update.
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
var t0 = DateTime.UtcNow;
|
||||||
|
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||||
|
{
|
||||||
|
NodeId = "upsert-node", DriverInstanceId = "upsert-driver", HostName = "upsert-host",
|
||||||
|
State = DriverHostState.Running,
|
||||||
|
StateChangedUtc = t0, LastSeenUtc = t0,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var t1 = t0.AddSeconds(30);
|
||||||
|
await using (var ctx2 = NewContext())
|
||||||
|
{
|
||||||
|
var existing = await ctx2.DriverHostStatuses.SingleAsync(r =>
|
||||||
|
r.NodeId == "upsert-node" && r.DriverInstanceId == "upsert-driver" && r.HostName == "upsert-host");
|
||||||
|
existing.State = DriverHostState.Faulted;
|
||||||
|
existing.StateChangedUtc = t1;
|
||||||
|
existing.LastSeenUtc = t1;
|
||||||
|
existing.Detail = "transport reset by peer";
|
||||||
|
await ctx2.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var ctx3 = NewContext();
|
||||||
|
var final = await ctx3.DriverHostStatuses.AsNoTracking().SingleAsync(r =>
|
||||||
|
r.NodeId == "upsert-node" && r.HostName == "upsert-host");
|
||||||
|
final.State.ShouldBe(DriverHostState.Faulted);
|
||||||
|
final.Detail.ShouldBe("transport reset by peer");
|
||||||
|
// Only one row — a naive "always insert" would have created a duplicate PK and thrown.
|
||||||
|
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Enum_persists_as_string_not_int()
|
||||||
|
{
|
||||||
|
// Fluent config sets HasConversion<string>() on State — the DB stores 'Running' /
|
||||||
|
// 'Stopped' / 'Faulted' / 'Unknown' as nvarchar(16). Verify by reading the raw
|
||||||
|
// string back via ADO; if someone drops the conversion the column will contain '1'
|
||||||
|
// / '2' / '3' and this assertion fails. Matters because DBAs inspecting the table
|
||||||
|
// directly should see readable state names, not enum ordinals.
|
||||||
|
await using var ctx = NewContext();
|
||||||
|
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||||
|
{
|
||||||
|
NodeId = "enum-node", DriverInstanceId = "enum-driver", HostName = "enum-host",
|
||||||
|
State = DriverHostState.Faulted,
|
||||||
|
StateChangedUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
await using var conn = fixture.OpenConnection();
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT [State] FROM DriverHostStatus WHERE NodeId = 'enum-node'";
|
||||||
|
var rawValue = (string?)await cmd.ExecuteScalarAsync();
|
||||||
|
rawValue.ShouldBe("Faulted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private OtOpcUaConfigDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||||
|
.UseSqlServer(fixture.ConnectionString)
|
||||||
|
.Options;
|
||||||
|
return new OtOpcUaConfigDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ public sealed class SchemaComplianceTests
|
|||||||
"Namespace", "UnsArea", "UnsLine",
|
"Namespace", "UnsArea", "UnsLine",
|
||||||
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
|
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup",
|
||||||
"NodeAcl", "ExternalIdReservation",
|
"NodeAcl", "ExternalIdReservation",
|
||||||
|
"DriverHostStatus",
|
||||||
};
|
};
|
||||||
|
|
||||||
var actual = QueryStrings(@"
|
var actual = QueryStrings(@"
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic guards for Active Directory compatibility of the internal helpers
|
||||||
|
/// <see cref="LdapUserAuthenticator"/> relies on. We can't live-bind against AD in unit
|
||||||
|
/// tests — instead, we pin the behaviors AD depends on (DN-parsing of AD-style
|
||||||
|
/// <c>memberOf</c> values, filter escaping with case-preserving RDN extraction) so a
|
||||||
|
/// future refactor can't silently break the AD path while the GLAuth live-smoke stays
|
||||||
|
/// green.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class LdapUserAuthenticatorAdCompatTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExtractFirstRdnValue_parses_AD_memberOf_group_name_from_CN_dn()
|
||||||
|
{
|
||||||
|
// AD's memberOf values use uppercase CN=… and full domain paths. The extractor
|
||||||
|
// returns the first RDN's value regardless of attribute-type case, so operators'
|
||||||
|
// GroupToRole keys stay readable ("OPCUA-Operators" not "CN=OPCUA-Operators,...").
|
||||||
|
var dn = "CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com";
|
||||||
|
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("OPCUA-Operators");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractFirstRdnValue_handles_mixed_case_and_spaces_in_group_name()
|
||||||
|
{
|
||||||
|
var dn = "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com";
|
||||||
|
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("Domain Users");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractFirstRdnValue_also_works_for_OpenLDAP_ou_style_memberOf()
|
||||||
|
{
|
||||||
|
// GLAuth + some OpenLDAP deployments expose memberOf as ou=<group>,ou=groups,...
|
||||||
|
// The authenticator needs one extractor that tolerates both shapes since directories
|
||||||
|
// in the field mix them depending on schema.
|
||||||
|
var dn = "ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local";
|
||||||
|
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("WriteOperate");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscapeLdapFilter_prevents_injection_via_samaccountname_lookup()
|
||||||
|
{
|
||||||
|
// AD login names can contain characters that are meaningful to LDAP filter syntax
|
||||||
|
// (parens, backslashes). The authenticator builds filters as
|
||||||
|
// ($"({UserNameAttribute}={EscapeLdapFilter(username)})") so injection attempts must
|
||||||
|
// not break out of the filter. The RFC 4515 escape set is: \ → \5c, * → \2a, ( → \28,
|
||||||
|
// ) → \29, \0 → \00.
|
||||||
|
LdapUserAuthenticator.EscapeLdapFilter("admin)(cn=*")
|
||||||
|
.ShouldBe("admin\\29\\28cn=\\2a");
|
||||||
|
LdapUserAuthenticator.EscapeLdapFilter("domain\\user")
|
||||||
|
.ShouldBe("domain\\5cuser");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat()
|
||||||
|
{
|
||||||
|
// Regression guard: PR 31 introduced UserNameAttribute with a default of "uid" so
|
||||||
|
// existing deployments (pre-AD config) keep working. Changing the default breaks
|
||||||
|
// everyone's config silently; require an explicit review.
|
||||||
|
new LdapOptions().UserNameAttribute.ShouldBe("uid");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
|
||||||
|
/// when the port is unreachable so the test suite stays portable on boxes without a
|
||||||
|
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
|
||||||
|
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
|
||||||
|
/// not just the flow-shape unit tests from PR 19.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
|
||||||
|
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
|
||||||
|
/// Server authenticator has to work even when the Server process is on a machine that
|
||||||
|
/// doesn't have the Admin assemblies loaded, and the two share no code by design
|
||||||
|
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
|
||||||
|
/// construction, DN resolution, or memberOf parsing, these tests surface it.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "LiveLdap")]
|
||||||
|
public sealed class LdapUserAuthenticatorLiveTests
|
||||||
|
{
|
||||||
|
private const string GlauthHost = "localhost";
|
||||||
|
private const int GlauthPort = 3893;
|
||||||
|
|
||||||
|
private static bool GlauthReachable()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var task = client.ConnectAsync(GlauthHost, GlauthPort);
|
||||||
|
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLAuth dev directory groups are named identically to the OPC UA roles
|
||||||
|
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
|
||||||
|
// identity translation. The authenticator still exercises every step of the pipeline —
|
||||||
|
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
|
||||||
|
// data; the identity map just means the assertion is phrased with no surprise rename
|
||||||
|
// in the middle.
|
||||||
|
private static LdapOptions GlauthOptions() => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Server = GlauthHost,
|
||||||
|
Port = GlauthPort,
|
||||||
|
UseTls = false,
|
||||||
|
AllowInsecureLdap = true,
|
||||||
|
SearchBase = "dc=lmxopcua,dc=local",
|
||||||
|
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
|
||||||
|
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
|
||||||
|
// user's password, then stays on the service-account session for memberOf lookup.
|
||||||
|
// Without this path, GLAuth ACLs block the authenticated user from reading their
|
||||||
|
// own entry in full — a plain self-search returns zero results and the role list
|
||||||
|
// ends up empty.
|
||||||
|
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||||
|
ServiceAccountPassword = "serviceaccount123",
|
||||||
|
DisplayNameAttribute = "cn",
|
||||||
|
GroupAttribute = "memberOf",
|
||||||
|
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
|
||||||
|
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["ReadOnly"] = "ReadOnly",
|
||||||
|
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
|
||||||
|
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
|
||||||
|
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
|
||||||
|
["AlarmAck"] = "AlarmAck",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
private static LdapUserAuthenticator NewAuthenticator() =>
|
||||||
|
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Valid_credentials_bind_and_return_success()
|
||||||
|
{
|
||||||
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||||
|
|
||||||
|
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
result.DisplayName.ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
|
||||||
|
{
|
||||||
|
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
|
||||||
|
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
|
||||||
|
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
|
||||||
|
// (WriteOperate is the exact string the policy checks for), so the failure mode is
|
||||||
|
// concrete, not abstract.
|
||||||
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||||
|
|
||||||
|
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
|
||||||
|
{
|
||||||
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||||
|
|
||||||
|
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
|
||||||
|
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
|
||||||
|
// surface every mapped role, not just the primary group. Guards against a regression
|
||||||
|
// where the memberOf parsing stops after the first match or misses the primary-group
|
||||||
|
// fallback.
|
||||||
|
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||||
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
|
||||||
|
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
|
||||||
|
result.Roles.ShouldContain("AlarmAck");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wrong_password_returns_failure()
|
||||||
|
{
|
||||||
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||||
|
|
||||||
|
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_user_returns_failure()
|
||||||
|
{
|
||||||
|
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||||
|
|
||||||
|
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Empty_credentials_fail_without_touching_the_directory()
|
||||||
|
{
|
||||||
|
// Pre-flight guard — doesn't require GLAuth.
|
||||||
|
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldContain("Credentials", Case.Insensitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Opc.Ua.Configuration;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes LMX follow-up #6 — proves that two <see cref="IDriver"/> instances registered
|
||||||
|
/// on the same <see cref="DriverHost"/> land in isolated namespaces and their reads
|
||||||
|
/// route to the correct driver. The existing <see cref="OpcUaServerIntegrationTests"/>
|
||||||
|
/// only exercises a single-driver topology; this sibling fixture registers two.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each driver gets its own namespace URI of the form <c>urn:OtOpcUa:{DriverInstanceId}</c>
|
||||||
|
/// (per <c>DriverNodeManager</c>'s base-class <c>namespaceUris</c> argument). A client
|
||||||
|
/// that browses one namespace must see only that driver's subtree, and a read against a
|
||||||
|
/// variable in one namespace must return that driver's value, not the other's — this is
|
||||||
|
/// what stops a cross-driver routing regression from going unnoticed when the v1
|
||||||
|
/// single-driver code path gets new knobs.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class MultipleDriverInstancesIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
||||||
|
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaMultiDriverTest";
|
||||||
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multi-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
private DriverHost _driverHost = null!;
|
||||||
|
private OpcUaApplicationHost _server = null!;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
_driverHost = new DriverHost();
|
||||||
|
await _driverHost.RegisterAsync(new StubDriver("alpha", folderName: "AlphaFolder", readValue: 42),
|
||||||
|
"{}", CancellationToken.None);
|
||||||
|
await _driverHost.RegisterAsync(new StubDriver("beta", folderName: "BetaFolder", readValue: 99),
|
||||||
|
"{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var options = new OpcUaServerOptions
|
||||||
|
{
|
||||||
|
EndpointUrl = _endpoint,
|
||||||
|
ApplicationName = "OtOpcUaMultiDriverTest",
|
||||||
|
ApplicationUri = "urn:OtOpcUa:Server:MultiDriverTest",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||||
|
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
await _server.StartAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _server.DisposeAsync();
|
||||||
|
await _driverHost.DisposeAsync();
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Both_drivers_register_under_their_own_urn_namespace()
|
||||||
|
{
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
|
||||||
|
var alphaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||||
|
var betaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||||
|
|
||||||
|
alphaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'alpha' must register its namespace URI");
|
||||||
|
betaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'beta' must register its namespace URI");
|
||||||
|
alphaNs.ShouldNotBe(betaNs, "each driver owns its own namespace");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Each_driver_subtree_exposes_only_its_own_folder()
|
||||||
|
{
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
|
||||||
|
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||||
|
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||||
|
|
||||||
|
var alphaRoot = new NodeId("alpha", alphaNs);
|
||||||
|
session.Browse(null, null, alphaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||||
|
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var alphaRefs);
|
||||||
|
alphaRefs.ShouldContain(r => r.BrowseName.Name == "AlphaFolder",
|
||||||
|
"alpha's subtree must contain alpha's folder");
|
||||||
|
alphaRefs.ShouldNotContain(r => r.BrowseName.Name == "BetaFolder",
|
||||||
|
"alpha's subtree must NOT see beta's folder — cross-driver leak would hide subscription-routing bugs");
|
||||||
|
|
||||||
|
var betaRoot = new NodeId("beta", betaNs);
|
||||||
|
session.Browse(null, null, betaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||||
|
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var betaRefs);
|
||||||
|
betaRefs.ShouldContain(r => r.BrowseName.Name == "BetaFolder");
|
||||||
|
betaRefs.ShouldNotContain(r => r.BrowseName.Name == "AlphaFolder");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reads_route_to_the_correct_driver_by_namespace()
|
||||||
|
{
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
|
||||||
|
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||||
|
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||||
|
|
||||||
|
var alphaValue = session.ReadValue(new NodeId("AlphaFolder.Var1", alphaNs));
|
||||||
|
var betaValue = session.ReadValue(new NodeId("BetaFolder.Var1", betaNs));
|
||||||
|
|
||||||
|
alphaValue.Value.ShouldBe(42, "alpha driver's ReadAsync returns 42 — a misroute would surface as 99");
|
||||||
|
betaValue.Value.ShouldBe(99, "beta driver's ReadAsync returns 99 — a misroute would surface as 42");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ISession> OpenSessionAsync()
|
||||||
|
{
|
||||||
|
var cfg = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUaMultiDriverTestClient",
|
||||||
|
ApplicationUri = "urn:OtOpcUa:MultiDriverTestClient",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
|
{
|
||||||
|
ApplicationCertificate = new CertificateIdentifier
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||||
|
SubjectName = "CN=OtOpcUaMultiDriverTestClient",
|
||||||
|
},
|
||||||
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||||
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||||
|
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||||
|
AutoAcceptUntrustedCertificates = true,
|
||||||
|
AddAppCertToTrustedStore = true,
|
||||||
|
},
|
||||||
|
TransportConfigurations = new TransportConfigurationCollection(),
|
||||||
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||||
|
};
|
||||||
|
await cfg.Validate(ApplicationType.Client);
|
||||||
|
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||||
|
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||||
|
|
||||||
|
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||||
|
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||||
|
|
||||||
|
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaMultiDriverTestClientSession", 60000,
|
||||||
|
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver stub that returns a caller-specified folder + variable + read value so two
|
||||||
|
/// instances in the same server can be told apart at the assertion layer.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StubDriver(string driverInstanceId, string folderName, int readValue)
|
||||||
|
: IDriver, ITagDiscovery, IReadable
|
||||||
|
{
|
||||||
|
public string DriverInstanceId => driverInstanceId;
|
||||||
|
public string DriverType => "Stub";
|
||||||
|
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var folder = builder.Folder(folderName, folderName);
|
||||||
|
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
|
||||||
|
$"{folderName}.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
IReadOnlyList<DataValueSnapshot> result =
|
||||||
|
fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray();
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user