25 Commits

Author SHA1 Message Date
Joseph Doherty 075c0e69da feat(audit): OtOpcUa IAuditActorAccessor seam + HTTP impl (audit Actor from Auth principal) (Phase 3)
v2-ci / build (push) Failing after 40s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Introduces the IAuditActorAccessor seam and HttpAuditActorAccessor impl so the
ZB.MOM.WW.Audit.AuditEvent Actor field can be sourced from the authenticated Blazor
cookie principal (ZbClaimTypes.Username) when structured emitters are added. Adds the
AuditActor.Resolve static helper (accessor value → SystemFallback/"system") as the
canonical pattern for future emit sites. Wires DI in AddOtOpcUaAuth (TryAddScoped) with
AddHttpContextAccessor(). The structured AuditEvent path remains DORMANT — no live emit
sites exist; seam is forward-looking. SP-based audit path left untouched. 9 new unit
tests all green; Security (54) and ControlPlane (45) test suites fully pass.
2026-06-02 15:25:49 -04:00
Joseph Doherty b7f5e887ee feat(audit): OtOpcUa ConfigAuditLog.Outcome column + migration + ClusterAudit visibility fix (Task 2.2)
Persist the canonical AuditOutcome and make structured audit rows visible.

- ConfigAuditLog gains a nullable Outcome column, stored as the AuditOutcome
  enum member name (nvarchar(16), mirroring how AdminRole is persisted). The
  AuditWriterActor flush now writes Outcome = evt.Outcome.ToString(). Nullable so
  legacy rows and the bespoke stored-procedure path (no derived outcome) write
  NULL.
- Migration 20260602135350_AddConfigAuditLogOutcome: additive nullable column,
  no backfill. Up adds the column, Down drops it. Chains after
  20260602112419_CanonicalizeAdminRoles; `dotnet ef migrations
  has-pending-model-changes` is clean.
- ClusterAudit visibility fix: the page filtered solely on ClusterId, but the
  structured AuditWriterActor path stamps NodeId (ClusterId null), so those rows
  were invisible. Extracted ClusterAuditQuery.ForClusterAsync (shared by the page
  and tests) which ORs in rows whose NodeId belongs to a node in the cluster —
  membership resolved from ClusterNode (NodeId -> ClusterId). SP-path
  ClusterId-stamped rows still match.

Tests: ControlPlane 45/45 (adds Outcome persistence + Denied-outcome asserts);
new Configuration ClusterAuditQueryTests 3/3 (both-paths visible, other-cluster
excluded, page-size cap); AdminUI 121/121. Configuration Unit suite is green on a
clean run (a pre-existing timing flake in ResilientConfigReaderTests, untouched
here, occasionally fails under parallel load and passes in isolation).
2026-06-02 09:59:22 -04:00
Joseph Doherty 933dd1a874 feat(audit): OtOpcUa adopt canonical ZB.MOM.WW.Audit.AuditEvent + AuditWriterActor:IAuditWriter + Outcome derivation (Task 2.1)
Deep-adopt the shared audit record. Deletes the bespoke 8-field positional
Commons AuditEvent and repoints the writer path at ZB.MOM.WW.Audit.AuditEvent
(0.1.0, feed-mapped via dohertj2-gitea). Adds the package reference to both
Commons and ControlPlane.

- AuditWriterActor now implements IAuditWriter: WriteAsync(evt, ct) is a
  best-effort, never-throwing entry point that Self.Tell()s the event onto the
  same batching/dedup/flush pipeline and returns Task.CompletedTask. Existing
  Receive<AuditEvent> + 500/5s batching + two-layer dedup unchanged.
- Flush mapping updated for the canonical field types: OccurredAtUtc is now
  DateTimeOffset (.UtcDateTime into the datetime2 column), SourceNode is string?
  (was NodeId.Value), CorrelationId is Guid? (stored null when null). Outcome is
  NOT yet persisted (column lands in Task 2.2).
- New AuditOutcomeMapper.FromAction maps the OtOpcUa action vocabulary to the
  required canonical Outcome: OpcUaAccessDenied / CrossClusterNamespaceAttempt ->
  Denied; config verbs (DraftCreated/Edited, Published, RolledBack, NodeApplied,
  ClusterCreated, NodeAdded, CredentialAdded/Disabled, ExternalIdReleased) ->
  Success. OtOpcUa emits no Failure events.

The Akka message shape changed, but the structured audit path is dormant (zero
production emit/Tell sites; all live audit flows through the bespoke SP path),
so there is no rolling-deploy wire-compat concern. Tested-not-exercised by
design.

ControlPlane.Tests: 44/44 green (AuditWriterActor suite rewritten to construct
the canonical record + assert the Outcome derivation table + the WriteAsync
best-effort/mailbox-routing contract + null SourceNode/CorrelationId handling).
2026-06-02 09:53:12 -04:00
Joseph Doherty c1619d95f5 feat(auth)!: OtOpcUa canonical control-plane roles + config-DB migration (Task 1.7)
Standardize the control-plane admin role VALUES on the canonical six
(ZB.MOM.WW.Auth CanonicalRole). OtOpcUa uses four:
  ConfigViewer   -> Viewer
  ConfigEditor   -> Designer
  FleetAdmin     -> Administrator
  DriverOperator -> Operator   (appsettings-only string role)

This is a rename, not a permission change: enforcement semantics are
preserved (whoever could deploy/administer/operate before still can).

- AdminRole enum members renamed (persisted as string names via
  HasConversion<string>); RoleGrants.razor dropdown default updated.
- EF DATA migration CanonicalizeAdminRoles rewrites existing
  LdapGroupRoleMapping.Role rows old->new (Up) and back (Down); schema /
  model snapshot byte-identical (no pending model changes).
- Enforcement role STRINGS canonicalized:
  * Security policies keep their NAMES ("DriverOperator"/"FleetAdmin")
    but require canonical roles: RequireRole("Operator","Administrator")
    and RequireRole("Administrator").
  * Deployments.razor [Authorize(Roles="Administrator,Designer")].
  * DevStub now grants "Administrator"; LdapOptions/doc-comment examples
    canonicalized.
- Data-plane authorization (NodePermissions/NodeAcl/IPermissionEvaluator/
  TriePermissionEvaluator/UserAuthorizationState) UNTOUCHED.
- New CanonicalAdminRolesTests pins canonical claim values end-to-end and
  the real registered policies; existing role-string tests updated.
2026-06-02 07:30:00 -04:00
Joseph Doherty 8ba289f975 chore(auth): OtOpcUa unify dev LDAP base DN to dc=zb,dc=local (Task 1.6)
Replace all dev-directory dc=lmxopcua,dc=local references with dc=zb,dc=local
across LdapOptions default, integration harness overrides, docker-compose LDAP_ROOT,
AclEdit placeholder DN, and dev/smoke-test docs. CN/OU prefixes preserved.
2026-06-02 06:45:23 -04:00
Joseph Doherty d0777eee29 fix(auth): OtOpcUa Task 1.5 review — pin JWT role-claim test + document issued-only JWT role key
Fix 1 (test): Token_payload_uses_canonical_zb_claim_keys now asserts that the JWT
payload carries at least one role under JwtTokenService.RoleClaimType ("Role"),
pinning the role-key contract so a future rename is caught immediately. Adds a
comment explaining why alice has roles (appsettings "ReadOnly"→"ConfigViewer"
baseline). Adds missing `using ZB.MOM.WW.OtOpcUa.Security.Jwt` to the test file.

Fix 2 (no-validation path — no AddJwtBearer in production pipeline): grep of src/
confirms no AddJwtBearer / JwtBearer scheme in ServiceCollectionExtensions or Host;
the ServiceCollectionExtensions doc comment explicitly states "no JwtBearer parallel
scheme". RoleClaimType intentionally stays the short "Role" key. Three changes:
  - RoleClaimType doc comment documents issued-only nature, the caveat that a
    JwtBearer scheme MUST use BuildValidationParameters(), and that BuildValidationParameters
    is already wired to set RoleClaimType+NameClaimType correctly.
  - Issue() inline comment at the role-mint site references RoleClaimType docs.
  - BuildValidationParameters() now sets RoleClaimType=RoleClaimType and
    NameClaimType=UsernameClaimType so that if it is ever passed to AddJwtBearer,
    role/name resolution is correct without any extra wiring. TryValidate() is
    refactored to delegate to BuildValidationParameters() so the two can never drift.

All 35 security tests green.
2026-06-02 06:30:10 -04:00
Joseph Doherty 83856b7c27 feat(auth): OtOpcUa adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
Add ZB.MOM.WW.Auth.AspNetCore package ref to Security project (version 0.1.1
from central PM). Alias JwtTokenService.UsernameClaimType and DisplayNameClaimType
to ZbClaimTypes.Username ("zb:username") and ZbClaimTypes.DisplayName ("zb:displayname")
so every mint/read site inherits the canonical spelling. AuthEndpoints login path now
emits ZbClaimTypes.Name (= ClaimTypes.Name, populates Identity.Name) instead of
ClaimTypes.NameIdentifier (no other read site used it), and references ZbClaimTypes.Role
(= ClaimTypes.Role) for role claims so [Authorize(Roles=...)] continues to resolve.
Cookie hardening now flows through ZbCookieDefaults.Apply (sets HttpOnly, SameSite=Strict,
SlidingExpiration, SecurePolicy, ExpireTimeSpan) followed by opts.Cookie.Name = v.Name to
preserve the OtOpcUa-specific "ZB.MOM.WW.OtOpcUa.Auth" cookie name. Two new tests added
to AuthEndpointsIntegrationTests assert canonical ZbClaimTypes on the cookie principal and
canonical zb: keys in the JWT payload; all 35 security tests green.
2026-06-02 06:11:00 -04:00
Joseph Doherty c4f315ec90 fix(auth): OtOpcUa 1.2 review fixes — startup insecure-transport guard + Ldaps in prod overlays, test fidelity, 0.1.1 pin 2026-06-02 01:37:29 -04:00
Joseph Doherty 257caa7bd1 feat(auth): cut OtOpcUa over to ZB.MOM.WW.Auth.Ldap; preserve DevStubMode; route roles via IGroupRoleMapper (Task 1.2/1.4) 2026-06-02 00:55:10 -04:00
Joseph Doherty 6534875476 feat(auth): add IGroupRoleMapper<string> seam (Task 1.1) 2026-06-02 00:29:45 -04:00
Joseph Doherty d2d7730830 build: add ZB.MOM.WW.Auth/Audit feed mapping + version pins
Maps ZB.MOM.WW.Auth, ZB.MOM.WW.Auth.*, ZB.MOM.WW.Audit to the gitea feed
and pins Auth.Abstractions/Ldap/AspNetCore + Audit at 0.1.0. No project
references yet (added during Phase 1/2 adoption). OtOpcUa omits Auth.ApiKeys
(OPC UA transport security).
2026-06-02 00:16:39 -04:00
Joseph Doherty 2844180865 fix: honor LdapOptions.Enabled at runtime; dedupe ILdapAuthService registration; +SearchBase test, doc fix
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-01 23:03:12 -04:00
Joseph Doherty d3ab2bfbaf fix: bind OtOpcUa LdapOptions from real Security:Ldap section; gate validator on DevStubMode 2026-06-01 22:46:09 -04:00
Joseph Doherty 88e773af36 feat: validate OpcUa host options at startup (route through IOptions + ValidateOnStart) 2026-06-01 18:45:55 -04:00
Joseph Doherty f35ebd7aaf feat: add fail-fast LDAP options validation in OtOpcUa via ZB.MOM.WW.Configuration 2026-06-01 18:32:44 -04:00
Joseph Doherty 0cbb82e466 build: add ZB.MOM.WW.Configuration feed mapping + version pin 2026-06-01 18:10:28 -04:00
Joseph Doherty 7b6884031d Merge feat/telemetry-followons: telemetry follow-ons for OtOpcUa
v2-ci / build (push) Failing after 34s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Serilog.AspNetCore/Extensions.Hosting/Settings.Configuration aligned to 10.0.0;
config-driven OTLP exporter opt-in (default Prometheus; also makes recorded
spans exportable when OTLP is configured).
2026-06-01 17:17:23 -04:00
Joseph Doherty 7ff7a60ae0 feat(otopcua): config-driven OTLP exporter opt-in (default Prometheus) 2026-06-01 16:40:24 -04:00
Joseph Doherty 8faa2bf23d build(otopcua): align Serilog.AspNetCore/Extensions.Hosting/Settings.Configuration to 10.0.0 2026-06-01 16:35:34 -04:00
Joseph Doherty 2099713ed8 Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across OtOpcUa
v2-ci / build (push) Failing after 51s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
AddZbTelemetry (shared OTel Resource identity + standard instrumentation;
kept meter ZB.MOM.WW.OtOpcUa + /metrics) and AddZbSerilog (shared enrichers +
trace correlation; sinks moved to appsettings). Behaviour-preserving.
2026-06-01 16:05:34 -04:00
Joseph Doherty c05ffc7b39 build(otopcua): add <clear/> to NuGet.config packageSources for supply-chain hygiene parity 2026-06-01 16:03:15 -04:00
Joseph Doherty 60017177cb feat(otopcua): adopt AddZbSerilog (shared enrichers + trace correlation); sinks to config 2026-06-01 15:41:21 -04:00
Joseph Doherty 26bae36f8b feat(otopcua): wire OTel via AddZbTelemetry (shared Resource + std instrumentation) 2026-06-01 15:33:28 -04:00
Joseph Doherty 368390ea9d build(otopcua): reference ZB.MOM.WW.Telemetry packages from Gitea feed 2026-06-01 15:29:46 -04:00
Joseph Doherty 8f950722c6 Merge feat/adopt-zb-health: adopt ZB.MOM.WW.Health shared probes (OtOpcUaCompat policy, admin-leader, ProbeQuery)
v2-ci / build (push) Failing after 5m5s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-01 14:07:02 -04:00
61 changed files with 6023 additions and 440 deletions
+11 -4
View File
@@ -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>
+7
View File
@@ -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>
+1 -1
View File
@@ -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 |
+2 -2
View File
@@ -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&lt;string&gt;</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,
}
@@ -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&lt;string&gt;</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';");
}
}
}
@@ -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");
}
}
}
@@ -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.");
}
}
@@ -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);
+27 -8
View File
@@ -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-contextbacked <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&lt;string&gt;</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
}
}
@@ -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);
}
@@ -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)");
}
}
@@ -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"]);
}