From 804c32e5c089e3d7c731d5e75353757f1886e035 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 12:12:35 -0400 Subject: [PATCH 01/10] docs(acl): design for API-key identity + node ACL enforcement (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decisions 2026-07-31: the OPC UA data plane is machine-to-machine, so identity is an API key with a static username rather than an LDAP user; LDAP is replaced entirely on the data plane (AdminUI login keeps its own); and Anonymous becomes a normal grantable principal behind an enable setting rather than a special case in the evaluator. Re-verified all six of #520's claims on master first — they still hold. The identity decision dissolves blocker #2 (session carries roles, not groups) rather than solving it. Blocker #1 (raw-realm nodes have no representable NodeScope) turns out to be tractable: NodeAclScopeKind already carries Namespace and FolderSegment, so adding NodeHierarchyKind.Raw over the RawPath segments makes FolderSegment matchable for the first time. Seam survey corrects #520 in both directions: HistoryRead already has four overrides to hang a check on, but Read/Browse/Subscribe genuinely have none and sit on the hot publish path, so decisions must be cached per session+node. Records two things the issue did not: enforcement ships behind a default-off Security:Acl:Enabled dark switch (otherwise the first deploy silently denies ScadaBridge, which consumes this address space from another repo), and because Phase 4 leaves driver-only nodes with no ConfigDb, keys ride the deployment artifact — so key revocation requires a deploy. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- ...026-07-31-apikey-acl-enforcement-design.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/plans/2026-07-31-apikey-acl-enforcement-design.md diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md new file mode 100644 index 00000000..d37daf25 --- /dev/null +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -0,0 +1,147 @@ +# API-key identity + node ACL enforcement (Gitea #520) + +**Status:** design agreed 2026-07-31, not yet implemented. +**Supersedes:** `docs/v2/acl-design.md` (LDAP-group-keyed model, never wired). + +## Why this exists + +`IPermissionEvaluator` + `TriePermissionEvaluator` + `PermissionTrieCache` + `PermissionTrieBuilder` +have lived in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` with 30 passing unit tests and **zero +production call sites** since v2. Operators can author `NodeAcl` rows at `/clusters/{id}/acls`; +`ConfigComposer` snapshots every row into every deployment artifact, so an ACL edit shifts the +artifact `RevisionHash` and ships fleet-wide — and `DeploymentArtifact` never deserializes them. The +bytes arrive and are dropped. + +Actual enforcement today is two server-wide role checks: `WriteOperate` on Value writes (either +realm) and `AlarmAck` on the five Part 9 alarm methods. **Read, Browse, HistoryRead and +CreateMonitoredItems have no authorization check at all**, so any admitted session — including +Anonymous — reads the entire address space. + +Re-verified on `master` 2026-07-31 (all six claims still true). Both AdminUI warning banners and the +corrected docs from 2026-07-27 are in place, so the trap is signposted but still a trap. + +## Decisions taken (owner, 2026-07-31) + +| # | Decision | Consequence | +|---|---|---| +| D1 | **The OPC UA data plane is machine-to-machine.** Identity is an **API key** presented with a static username, not an LDAP user. | `NodeAcl` keys on a principal, not an LDAP group — blocker #2 dissolves rather than being solved. | +| D2 | **Replace LDAP entirely on the OPC UA data plane.** | `LdapOpcUaUserAuthenticator` and the OPC UA half of `Security:Ldap` are deleted. **AdminUI login keeps its own LDAP** — untouched. | +| D3 | **Anonymous is a normal, grantable principal**, gated by a new enable setting. | When enabled, Anonymous resolves to the reserved principal `anonymous` and can be granted read *or* write like any key. When disabled, anonymous session activation is rejected. | + +D3 is the neat one: it removes Anonymous as a special case in the evaluator. There is no +"anonymous bypass" branch to get wrong — an ungranted anonymous session is denied by the same +fail-closed path as an ungranted key. + +## Assumption I am making explicit (not asked) + +**Enforcement ships behind `Security:Acl:Enabled`, default `false`.** This is the dark-switch +discipline `MeshTransport` / `ConfigSource` / `Telemetry` all shipped under: both sides are wired in +both modes, the flip is a config change rather than a redeploy. Without it, the first deploy carrying +this code would silently start denying every client that has no key — including **ScadaBridge's Data +Connection Layer, which consumes this address space from another repo**. The authenticator swap (D2) +is *not* dark-switchable in the same way and is a hard cutover; that asymmetry is deliberate and +called out in the rollout stage. + +## Identity model + +``` +OPC UA UserName token Anonymous token + username = (no credential) + password = + | | + v v + ApiKeyOpcUaUserAuthenticator Security:Acl:AllowAnonymous ? + | | + v v + principal set <------------- "anonymous" + | + v + PermissionTrie (per cluster, per artifact revision) +``` + +- **Key format** mirrors the family convention (`histgw__` in HistorianGateway, + `apikey create-key` in mxaccessgw): `otua__`. The `keyId` prefix makes lookup an + index hit rather than a scan over every stored hash. +- **Storage is a hash, never the secret** — HMAC-SHA256 under a server pepper, compared with + `CryptographicOperations.FixedTimeEquals`. The plaintext is shown **once** at creation in AdminUI + and is unrecoverable afterwards. +- **A key maps to one or more named principals**, and `NodeAcl` rows key on the principal name. The + indirection is deliberate: many keys (one per consuming system, independently revocable) share one + grant set, and rotating a key never touches an ACL row. + +### `NodeAcl.LdapGroup` → `NodeAcl.Principal` + +A rename + migration, not a new column. The trie walker is unchanged — it already matches an opaque +string; only what produces that string changes. + +## The four blockers, re-scoped + +| # | #520's blocker | Resolution under this design | +|---|---|---| +| 1 | Raw-realm nodes have no representable `NodeScope` (`NodeHierarchyKind` has one member, `Equipment`) | **Add `NodeHierarchyKind.Raw`**, whose path is the RawPath segments `Folder/Driver/Device/TagGroup/Tag`. `NodeAclScopeKind` already carries `Namespace` and `FolderSegment` — `FolderSegment` becomes matchable for the first time, at raw-folder level. This is the one genuine **design** item, not wiring. | +| 2 | Session carries roles, not LDAP groups | **Dissolved by D1** — the principal comes from the key, not from a directory. | +| 3 | `PermissionTrieBuilder`'s `scopePaths` has no producer | Built node-side from the artifact's UNS hierarchy + raw tree. Note every existing evaluator unit test runs in the no-`scopePaths` "deterministic test mode", so **the production hierarchy path is currently untested** — it needs its own coverage, not just a producer. | +| 4 | No session lifecycle / `AuthGenerationId` | Stamp the generation from the artifact `RevisionHash` at session activation; a new deploy invalidates cached decisions. `UserAuthorizationState` already has the freshness predicates. | + +Plus, from the same audit: the node never parses ACLs out of the artifact, and `OtOpcUaNodeManager` +has no cluster identity (`NodeScope.ClusterId` is `required` and the evaluator fails closed on +mismatch). + +## Enforcement seams + +| Operation | Seam today | Work | +|---|---|---| +| Write (Value) | `EvaluateEquipmentWriteGate` — pure, fail-closed, unit-testable | Swap the role check for an ACL decision. Runs under the node-manager `Lock`, so the check must stay non-blocking — the evaluator is pure, which fits. | +| Alarm ack/confirm/shelve | two `AlarmAck` role gates | Same swap. | +| HistoryRead | `HistoryReadRawModified` / `Processed` / `AtTime` / `Events` overrides **already exist** | Add the check inside them. | +| **Read / Browse / Subscribe** | **none** | The real gap. Needs a `Read` override or per-variable `OnSimpleReadValue`, on the hot publish path — so the decision must be cached per (session, node), not evaluated per value. | + +## Smaller inconsistencies to settle en route + +- `NodeAclScopeKind.FolderSegment` is offered in the authoring dropdown but the trie walker has no + level to match it at — fixed by blocker #1's `Raw` kind. +- `NodePermissions.AlarmRead` has no `OpcUaOperation` mapping — grantable but unreachable. +- `NodeAcl.ScopeId` has no FK and no existence check, so scope ids dangle silently. +- `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference `Core` at all — the evaluator is not even + on the node manager's compile-time reference graph. +- `NodePermissions.HistoryUpdate` is deliberately in no bundle until HistoryUpdate exists. Leave it. + +## Stages + +Each stage is one commit, builds clean, and leaves the tree shippable. + +1. **Foundation — principal model.** `NodeAcl.LdapGroup` → `Principal` (+ migration); `ApiKey` entity + + store + hashing; `OpcUaServer.csproj` references `Core`. No behaviour change. +2. **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the + OPC UA data plane; Anonymous resolves to the `anonymous` principal or is rejected per setting. + AdminUI LDAP untouched. +3. **Raw scope.** `NodeHierarchyKind.Raw` + RawPath scope paths; `scopePaths` producer built from the + artifact; coverage for the production hierarchy path that today has none. +4. **Artifact + lifecycle.** Node-side ACL/key parsing out of the artifact; cluster identity on the + node manager; `AuthGenerationId` from `RevisionHash`. +5. **Enforce write + alarms + HistoryRead** behind `Security:Acl:Enabled`, using the seams that + already exist. +6. **Enforce read/browse/subscribe** — the new seam, with per-session decision caching. +7. **AdminUI.** Key management (create/revoke, show-once), ACL authoring against principals, and + **remove the two "not enforced" warning banners** — which is the honest signal that this is done. +8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on docker-dev + as the live gate. + +## Live gate (stage 8) + +On docker-dev, with `Security:Acl:Enabled=true`: + +1. A key granted `ReadOnly` on one equipment reads its tags and is denied a tag outside the grant. +2. The same key is denied a write; a key granted `Operator` writes successfully. +3. Anonymous with the setting off cannot activate a session; with it on and a `ReadOnly` grant, it + reads and is denied writes. +4. Revoking a key denies the next request **after a deploy** — see the caveat below. +5. A raw-realm grant (`NodeHierarchyKind.Raw`) and a UNS grant both evaluate, proving blocker #1. + +## Known consequence to design around + +**Driver-only nodes have no ConfigDb (Phase 4).** Keys and ACLs must therefore reach them inside the +**deployment artifact**, exactly as `NodeAcl` rows already do. That means **key revocation requires a +deploy** — there is no out-of-band revocation channel to a site node. This is a real operational +sharp edge and must be documented rather than discovered; if immediate revocation is needed later, +the Phase 5 gRPC telemetry stream is the existing node-reachable channel to carry it. From 4d376f727f06e0f60c65892c8daf7f4e3dfe9ceb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 12:17:57 -0400 Subject: [PATCH 02/10] refactor(acl): NodeAcl.LdapGroup -> Principal, with the proc that reads it (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1a of the API-key ACL design. Behaviour-neutral: the trie walker already matched an opaque string, so only what produces that string changes. No enforcement is wired by this commit and none of the two live role gates move. Renamed through the ACL model: NodeAcl.Principal, TrieGrant.Principal, MatchedGrant.Principal, AuthorizationDecision.Principal, UserAuthorizationState.Principals, and PermissionTrie.CollectMatches's parameter. LdapGroupRoleMapping is a DIFFERENT entity that also has an LdapGroup column and is deliberately untouched — it backs AdminUI login, which keeps LDAP. The migration is the interesting part. EF scaffolds RenameColumn/RenameIndex, so rows survive — but it cannot know that dbo.sp_ComputeGenerationDiff checksums NodeAcl.LdapGroup. That proc is created in V3Initial.StoredProcedures and never redefined since, and SQL Server does not validate a procedure body when a column is renamed out from under it: the object keeps reporting healthy and fails at CALL time with Invalid column name, silently breaking config-drift detection. Verified against the live dev DB that the deployed proc really does reference the column. The migration therefore re-emits the proc, as one parameterised template shared by Up and Down so the two cannot drift. Prose that describes what is enforced TODAY (fleet-wide LDAP group -> role) is left alone, because it is still accurate until stage 2 lands. UserAuthorizationState's refresh machinery is retained unchanged but now carries a note that it is LDAP-shaped and largely vestigial here: an API-key principal set cannot change out of band, since keys and ACL rows both ride the deployment artifact, so the generation stamp rather than the freshness timer is the real invalidation signal. 41 authorization tests pass; solution builds clean. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- .../Entities/NodeAcl.cs | 6 +- ...ameNodeAclLdapGroupToPrincipal.Designer.cs | 1560 +++++++++++++++++ ...61543_RenameNodeAclLdapGroupToPrincipal.cs | 104 ++ .../OtOpcUaConfigDbContextModelSnapshot.cs | 20 +- .../OtOpcUaConfigDbContext.cs | 8 +- .../Authorization/AuthorizationDecision.cs | 4 +- .../Authorization/PermissionTrie.cs | 18 +- .../Authorization/PermissionTrieBuilder.cs | 2 +- .../Authorization/TriePermissionEvaluator.cs | 2 +- .../Authorization/UserAuthorizationState.cs | 29 +- .../Components/Pages/Clusters/AclEdit.razor | 12 +- .../Pages/Clusters/ClusterAcls.razor | 6 +- .../PermissionTrieBuilderTests.cs | 2 +- .../Authorization/PermissionTrieTests.cs | 2 +- .../TriePermissionEvaluatorTests.cs | 4 +- .../UserAuthorizationStateTests.cs | 2 +- 16 files changed, 1729 insertions(+), 52 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.Designer.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs index 18a8ab60..23e60490 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; /// -/// One ACL grant: an LDAP group gets a set of at a specific scope. +/// One ACL grant: a principal gets a set of at a specific scope. /// Generation-versioned per decision. See acl-design.md for evaluation algorithm. /// public sealed class NodeAcl @@ -17,8 +17,8 @@ public sealed class NodeAcl /// Gets or sets the cluster ID for this ACL entry. public required string ClusterId { get; set; } - /// Gets or sets the LDAP group for this ACL entry. - public required string LdapGroup { get; set; } + /// Gets or sets the principal (API-key principal name, or the reserved anonymous) this ACL entry grants to. + public required string Principal { get; set; } /// Gets or sets the scope kind for this ACL entry. public required NodeAclScopeKind ScopeKind { get; set; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.Designer.cs new file mode 100644 index 00000000..85050346 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.Designer.cs @@ -0,0 +1,1560 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260731161543_RenameNodeAclLdapGroupToPrincipal")] + partial class RenameNodeAclLdapGroupToPrincipal + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AkkaPort") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4053); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GrpcPort") + .HasColumnType("int"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("MaintenanceMode") + .HasColumnType("bit"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Outcome") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); + + b.HasIndex("DriverInstanceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Device_Driver_Name"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("RawFolderId") + .HasDatabaseName("IX_DriverInstance_RawFolder"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_ClusterRoot_Name") + .HasFilter("[RawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "RawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Folder_Name") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("Principal") + .HasDatabaseName("IX_NodeAcl_Principal"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Principal", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_PrincipalScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b => + { + b.Property("RawFolderRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentRawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("RawFolderRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_RawFolder_Cluster"); + + b.HasIndex("RawFolderId") + .IsUnique() + .HasDatabaseName("UX_RawFolder_LogicalId") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_ClusterRoot_Name") + .HasFilter("[ParentRawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "ParentRawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_Parent_Name") + .HasFilter("[ParentRawFolderId] IS NOT NULL"); + + b.ToTable("RawFolder", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Device_Name") + .HasFilter("[TagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "TagGroupId") + .HasDatabaseName("IX_Tag_Device"); + + b.HasIndex("DeviceId", "TagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Group_Name") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b => + { + b.Property("TagGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentTagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("TagGroupRowId"); + + b.HasIndex("DeviceId") + .HasDatabaseName("IX_TagGroup_Device"); + + b.HasIndex("TagGroupId") + .IsUnique() + .HasDatabaseName("UX_TagGroup_LogicalId") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_DeviceRoot_Name") + .HasFilter("[ParentTagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "ParentTagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_Parent_Name") + .HasFilter("[ParentTagGroupId] IS NOT NULL"); + + b.ToTable("TagGroup", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b => + { + b.Property("UnsTagReferenceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DisplayNameOverride") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsTagReferenceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsTagReferenceRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_UnsTagReference_Equipment"); + + b.HasIndex("TagId") + .HasDatabaseName("IX_UnsTagReference_Tag"); + + b.HasIndex("UnsTagReferenceId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_LogicalId") + .HasFilter("[UnsTagReferenceId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "TagId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_Equip_Tag"); + + b.ToTable("UnsTagReference", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null) + .WithMany("RawFolders") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Nodes"); + + b.Navigation("RawFolders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.cs new file mode 100644 index 00000000..f163caef --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731161543_RenameNodeAclLdapGroupToPrincipal.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + /// #520 — NodeAcl.LdapGroup becomes NodeAcl.Principal. The OPC UA data plane is + /// machine-to-machine, so an ACL grant keys on an API-key principal (or the reserved + /// anonymous), not on an LDAP group. EF scaffolds this as RenameColumn/RenameIndex, so + /// existing rows survive. + /// + /// + /// ⚠️ The column is also read by dbo.sp_ComputeGenerationDiff, created in + /// V3Initial.StoredProcedures and never redefined since. A bare column rename leaves that + /// proc referencing a column that no longer exists — SQL Server does not validate a procedure + /// body at rename time, so the object keeps reporting healthy and fails at CALL time with + /// Invalid column name 'LdapGroup', silently breaking config-drift detection. The proc is + /// therefore re-emitted below in the same migration, body otherwise byte-identical. If you add or + /// rename a column on any table that proc checksums, it needs the same treatment. + /// + public partial class RenameNodeAclLdapGroupToPrincipal : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "LdapGroup", + table: "NodeAcl", + newName: "Principal"); + + migrationBuilder.RenameIndex( + name: "UX_NodeAcl_GroupScope", + table: "NodeAcl", + newName: "UX_NodeAcl_PrincipalScope"); + + migrationBuilder.RenameIndex( + name: "IX_NodeAcl_Group", + table: "NodeAcl", + newName: "IX_NodeAcl_Principal"); + + migrationBuilder.Sql(ComputeGenerationDiff("Principal")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Principal", + table: "NodeAcl", + newName: "LdapGroup"); + + migrationBuilder.RenameIndex( + name: "UX_NodeAcl_PrincipalScope", + table: "NodeAcl", + newName: "UX_NodeAcl_GroupScope"); + + migrationBuilder.RenameIndex( + name: "IX_NodeAcl_Principal", + table: "NodeAcl", + newName: "IX_NodeAcl_Group"); + + migrationBuilder.Sql(ComputeGenerationDiff("LdapGroup")); + } + + /// + /// dbo.sp_ComputeGenerationDiff re-emitted with the NodeAcl grantee column named by + /// . Kept as one parameterised template so Up and Down cannot + /// drift from each other, and so the diff shows exactly one token changing. + /// + /// The NodeAcl grantee column name to checksum. + /// The CREATE OR ALTER PROCEDURE statement. + private static string ComputeGenerationDiff(string granteeColumn) => $@" +CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff + @FromGenerationId bigint, + @ToGenerationId bigint +AS +BEGIN + SET NOCOUNT ON; + + -- @FromGenerationId / @ToGenerationId are ignored: the v3 config tables are single live-state + -- (no GenerationId column). Emit a current-state signature per row so the shape (TableName, + -- LogicalId, Sig) still round-trips through any legacy caller. + SELECT TableName, LogicalId, Sig FROM ( + SELECT 'RawFolder' AS TableName, RawFolderId AS LogicalId, CHECKSUM(ClusterId, ParentRawFolderId, Name, SortOrder) AS Sig FROM dbo.RawFolder + UNION ALL + SELECT 'DriverInstance', DriverInstanceId, CHECKSUM(ClusterId, RawFolderId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) FROM dbo.DriverInstance + UNION ALL + SELECT 'Device', DeviceId, CHECKSUM(DriverInstanceId, Name, Enabled, CONVERT(varchar(max), DeviceConfig)) FROM dbo.Device + UNION ALL + SELECT 'TagGroup', TagGroupId, CHECKSUM(DeviceId, ParentTagGroupId, Name, SortOrder) FROM dbo.TagGroup + UNION ALL + SELECT 'Tag', TagId, CHECKSUM(DeviceId, TagGroupId, PollGroupId, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) FROM dbo.Tag + UNION ALL + SELECT 'Equipment', EquipmentId, CHECKSUM(EquipmentUuid, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) FROM dbo.Equipment + UNION ALL + SELECT 'UnsTagReference', UnsTagReferenceId, CHECKSUM(EquipmentId, TagId, DisplayNameOverride, SortOrder) FROM dbo.UnsTagReference + UNION ALL + SELECT 'NodeAcl', NodeAclId, CHECKSUM(ClusterId, {granteeColumn}, ScopeKind, ScopeId, PermissionFlags, Notes) FROM dbo.NodeAcl + ) AS inventory; +END +"; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index a2823b38..dc0cab3c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -709,11 +709,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("LdapGroup") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - b.Property("NodeAclId") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -725,6 +720,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("PermissionFlags") .HasColumnType("int"); + b.Property("Principal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + b.Property("RowVersion") .IsConcurrencyToken() .IsRequired() @@ -745,21 +745,21 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasIndex("ClusterId") .HasDatabaseName("IX_NodeAcl_Cluster"); - b.HasIndex("LdapGroup") - .HasDatabaseName("IX_NodeAcl_Group"); - b.HasIndex("NodeAclId") .IsUnique() .HasDatabaseName("UX_NodeAcl_LogicalId") .HasFilter("[NodeAclId] IS NOT NULL"); + b.HasIndex("Principal") + .HasDatabaseName("IX_NodeAcl_Principal"); + b.HasIndex("ScopeKind", "ScopeId") .HasDatabaseName("IX_NodeAcl_Scope") .HasFilter("[ScopeId] IS NOT NULL"); - b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + b.HasIndex("ClusterId", "Principal", "ScopeKind", "ScopeId") .IsUnique() - .HasDatabaseName("UX_NodeAcl_GroupScope") + .HasDatabaseName("UX_NodeAcl_PrincipalScope") .HasFilter("[ScopeId] IS NOT NULL"); b.ToTable("NodeAcl", (string)null); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index 1aac7060..0ca47c20 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -449,7 +449,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); e.Property(x => x.NodeAclId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); - e.Property(x => x.LdapGroup).HasMaxLength(256); + e.Property(x => x.Principal).HasMaxLength(256); e.Property(x => x.ScopeKind).HasConversion().HasMaxLength(16); e.Property(x => x.ScopeId).HasMaxLength(64); e.Property(x => x.PermissionFlags).HasConversion(); @@ -457,13 +457,13 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.RowVersion).IsRowVersion(); e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_NodeAcl_Cluster"); - e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_NodeAcl_Group"); + e.HasIndex(x => x.Principal).HasDatabaseName("IX_NodeAcl_Principal"); e.HasIndex(x => new { x.ScopeKind, x.ScopeId }) .HasFilter("[ScopeId] IS NOT NULL") .HasDatabaseName("IX_NodeAcl_Scope"); e.HasIndex(x => x.NodeAclId).IsUnique().HasDatabaseName("UX_NodeAcl_LogicalId"); - e.HasIndex(x => new { x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique() - .HasDatabaseName("UX_NodeAcl_GroupScope"); + e.HasIndex(x => new { x.ClusterId, x.Principal, x.ScopeKind, x.ScopeId }).IsUnique() + .HasDatabaseName("UX_NodeAcl_PrincipalScope"); }); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs index dfd43d26..80479ef2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs @@ -43,10 +43,10 @@ public enum AuthorizationVerdict } /// One grant that contributed to an Allow verdict — for audit / UI diagnostics. -/// LDAP group the matched grant belongs to. +/// Principal the matched grant belongs to. /// Where in the hierarchy the grant was anchored. /// The bitmask the grant contributed. public sealed record MatchedGrant( - string LdapGroup, + string Principal, NodeAclScopeKind Scope, NodePermissions PermissionFlags); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs index cc2a83e6..d12ff13b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; /// In-memory permission trie for one (ClusterId, GenerationId). Walk from the cluster /// root down through namespace → UNS levels → tag, OR-ing the /// granted at each visited level for each of the session's -/// LDAP groups. The accumulated bitmask is compared to the permission required by the +/// principals. The accumulated bitmask is compared to the permission required by the /// requested . /// /// @@ -28,18 +28,18 @@ public sealed class PermissionTrie /// /// Walk the trie collecting grants that apply to for any of the - /// session's . Returns the matched-grant list; the caller + /// session's . Returns the matched-grant list; the caller /// OR-s the flag bits to decide whether the requested permission is carried. /// /// The node scope to match permissions for. - /// The user's LDAP group memberships. + /// The principal names carried by the session (API-key principals, or the reserved "anonymous"). /// The matched grants accumulated across every trie level visited for the scope. - public IReadOnlyList CollectMatches(NodeScope scope, IEnumerable ldapGroups) + public IReadOnlyList CollectMatches(NodeScope scope, IEnumerable principals) { ArgumentNullException.ThrowIfNull(scope); - ArgumentNullException.ThrowIfNull(ldapGroups); + ArgumentNullException.ThrowIfNull(principals); - var groups = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase); + var groups = principals.ToHashSet(StringComparer.OrdinalIgnoreCase); if (groups.Count == 0) return []; var matches = new List(); @@ -83,8 +83,8 @@ public sealed class PermissionTrie { foreach (var grant in node.Grants) { - if (groups.Contains(grant.LdapGroup)) - matches.Add(new MatchedGrant(grant.LdapGroup, level, grant.PermissionFlags)); + if (groups.Contains(grant.Principal)) + matches.Add(new MatchedGrant(grant.Principal, level, grant.PermissionFlags)); } } } @@ -104,4 +104,4 @@ public sealed class PermissionTrieNode } /// Projection of a row into the trie. -public sealed record TrieGrant(string LdapGroup, NodePermissions PermissionFlags); +public sealed record TrieGrant(string Principal, NodePermissions PermissionFlags); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs index bccdf49d..60783693 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs @@ -57,7 +57,7 @@ public static class PermissionTrieBuilder foreach (var row in rows) { if (!string.Equals(row.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue; - var grant = new TrieGrant(row.LdapGroup, row.PermissionFlags); + var grant = new TrieGrant(row.Principal, row.PermissionFlags); var node = row.ScopeKind switch { diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs index b9ecead1..2c401a5b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs @@ -48,7 +48,7 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator if (trie is null) return AuthorizationDecision.NotGranted(); } - var matches = trie.CollectMatches(scope, session.LdapGroups); + var matches = trie.CollectMatches(scope, session.Principals); if (matches.Count == 0) return AuthorizationDecision.NotGranted(); var required = MapOperationToPermission(operation); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs index 53b5069d..3c3e501b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs @@ -2,14 +2,25 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; /// /// Per-session authorization state cached on the OPC UA session object + keyed on the -/// session id. Captures the LDAP group memberships resolved at sign-in, the generation -/// the membership was resolved against, and the bounded freshness window. +/// session id. Captures the principals resolved at session activation, the generation +/// they were resolved against, and the bounded freshness window. /// /// +/// ⚠️ The refresh machinery below is LDAP-shaped and is largely vestigial under the +/// API-key model (#520). It exists because an LDAP group membership can change +/// out-of-band in the directory, so a long-lived session had to re-resolve periodically. +/// An API-key principal set cannot change out-of-band: keys and ACL rows both ride the +/// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only +/// thing that can change a session's grants is a new deploy — which already bumps +/// . The generation stamp, not the freshness timer, is the +/// real invalidation signal. Revisit whether +/// / earn their keep +/// when the session lifecycle lands (design doc stage 4); they are retained unchanged here +/// so this rename stays behaviour-neutral. +/// /// The membership is bounded by -/// (default 5 min). After that, the next hot-path authz call re-resolves LDAP group -/// memberships; failure to re-resolve (LDAP unreachable) flips the session to fail-closed -/// until a refresh succeeds. +/// (default 5 min). After that, the next hot-path authz call re-resolves the principal +/// set; failure to re-resolve flips the session to fail-closed until a refresh succeeds. /// /// (default 15 min) is separate from /// Phase 6.1's availability-oriented 24h cache — beyond this window the evaluator returns @@ -30,12 +41,14 @@ public sealed record UserAuthorizationState public required string ClusterId { get; init; } /// - /// LDAP groups the user is a member of as resolved at sign-in / last membership refresh. + /// Principals the session carries, as resolved at session activation: the principal names + /// attached to the presented API key, or the single reserved anonymous principal for + /// an anonymous session when anonymous access is enabled. /// Case comparison is handled downstream by the evaluator (OrdinalIgnoreCase). /// - public required IReadOnlyList LdapGroups { get; init; } + public required IReadOnlyList Principals { get; init; } - /// Timestamp when was last resolved from the directory. + /// Timestamp when was last resolved. public required DateTime MembershipResolvedUtc { get; init; } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor index e07b9030..7061d22a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -52,8 +52,8 @@ else
- - Principal +
@@ -150,7 +150,7 @@ else _form = new FormModel { NodeAclId = _existing.NodeAclId, - LdapGroup = _existing.LdapGroup, + Principal = _existing.Principal, ScopeKind = _existing.ScopeKind, ScopeId = _existing.ScopeId, PermissionFlags = _existing.PermissionFlags, @@ -176,7 +176,7 @@ else { NodeAclId = _form.NodeAclId, ClusterId = ClusterId, - LdapGroup = _form.LdapGroup, + Principal = _form.Principal, ScopeKind = _form.ScopeKind, ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId, PermissionFlags = _form.PermissionFlags, @@ -188,7 +188,7 @@ else var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); if (entity is null) { _error = "Row no longer exists."; return; } db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; - entity.LdapGroup = _form.LdapGroup; + entity.Principal = _form.Principal; entity.ScopeKind = _form.ScopeKind; entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId; entity.PermissionFlags = _form.PermissionFlags; @@ -224,7 +224,7 @@ else private sealed class FormModel { [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string NodeAclId { get; set; } = ""; - [Required] public string LdapGroup { get; set; } = ""; + [Required] public string Principal { get; set; } = ""; public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster; public string? ScopeId { get; set; } public NodePermissions PermissionFlags { get; set; } = NodePermissions.None; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor index 7b49a930..f7179c5f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -33,7 +33,7 @@ else
- ACL rows grant LDAP groups specific NodePermissions on a scope + ACL rows grant principals specific NodePermissions on a scope (a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained per-node scope.
@@ -51,7 +51,7 @@ else NodeAclId - LDAP group + Principal Scope Scope target Permissions @@ -64,7 +64,7 @@ else { @a.NodeAclId - @a.LdapGroup + @a.Principal @a.ScopeKind @(a.ScopeId ?? "—") diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieBuilderTests.cs index c71117c3..86848b9a 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieBuilderTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieBuilderTests.cs @@ -29,7 +29,7 @@ public sealed class PermissionTrieBuilderTests NodeAclRowId = Guid.NewGuid(), NodeAclId = $"acl-{Guid.NewGuid():N}", ClusterId = clusterId, - LdapGroup = group, + Principal = group, ScopeKind = scope, ScopeId = scopeId, PermissionFlags = flags, diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs index 133b5e7c..fcf4ec74 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/PermissionTrieTests.cs @@ -15,7 +15,7 @@ public sealed class PermissionTrieTests NodeAclRowId = Guid.NewGuid(), NodeAclId = $"acl-{Guid.NewGuid():N}", ClusterId = clusterId, - LdapGroup = group, + Principal = group, ScopeKind = scope, ScopeId = scopeId, PermissionFlags = flags, diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs index 4d8b5c4e..76b6a2d9 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/TriePermissionEvaluatorTests.cs @@ -28,7 +28,7 @@ public sealed class TriePermissionEvaluatorTests NodeAclRowId = Guid.NewGuid(), NodeAclId = $"acl-{Guid.NewGuid():N}", ClusterId = "c1", - LdapGroup = group, + Principal = group, ScopeKind = scope, ScopeId = scopeId, PermissionFlags = flags, @@ -40,7 +40,7 @@ public sealed class TriePermissionEvaluatorTests { SessionId = "sess", ClusterId = clusterId, - LdapGroups = groups, + Principals = groups, MembershipResolvedUtc = resolvedUtc ?? Now, AuthGenerationId = authGenerationId, MembershipVersion = 1, diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs index 57adfb77..0dc023ec 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/UserAuthorizationStateTests.cs @@ -13,7 +13,7 @@ public sealed class UserAuthorizationStateTests { SessionId = "s", ClusterId = "c1", - LdapGroups = ["cn=ops"], + Principals = ["cn=ops"], MembershipResolvedUtc = resolved, AuthGenerationId = 1, MembershipVersion = 1, From f88eb49bd12ed3fefe00ff956bb6a32dee2df983 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 12:27:28 -0400 Subject: [PATCH 03/10] feat(acl): ApiKey entity + key format, and put Core on the server's graph (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1b. Still no enforcement wired — this adds the credential model the identity cutover (stage 2) will read. ApiKey holds one principal per key, deliberately. The reason to separate keys from principals is so many keys share a grant set and rotating a key never touches an ACL row, and that holds with one principal per key since nothing stops two keys naming the same principal. A key belonging to several principals at once buys little and costs a join table. UserAuthorizationState.Principals stays a list, so adding it later needs no ACL-side change. ApiKeySecret is the security-critical half and is pure, so it is tested on its own: otua__, peppered HMAC-SHA256, FixedTimeEquals. Notes why a plain HMAC rather than an iterated KDF (the secret is 32 bytes of CSPRNG, and the call runs inline on session activation, which the SDK serializes server-wide behind its SessionManager event lock), and fails closed on an unset pepper rather than hashing under an empty key. Found and fixed a bug in my own first draft: the key id was url-safe base64, whose alphabet maps '/' to '_' — the very character TryParse splits on. Roughly one key in five would have had its id truncated at a random position, an intermittent auth failure reproducing only for SOME keys. The id is hex now, and the guard is falsified: reverting the alphabet reds both The_generated_key_id_never_contains_the_separator and, as a bonus signal, Two_generated_keys_never_collide, because truncated ids collide. ApiKeyId is NOT NULL, unlike the nullable NodeAclId precedent whose filtered unique index would admit two NULL ids. Fine for a config row, not for a credentials lookup key. OpcUaServer.csproj now references Core. Until now it did not, so IPermissionEvaluator was literally uncallable from the node manager — the place enforcement has to happen. No new transitive package surface. Schema doc updated for both, and its NodeAcl block flagged for pre-existing drift (it still documents the GenerationId column v3 dropped) without fixing it here. Core 247, OpcUaServer 364, Configuration 128 — all green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- docs/v2/config-db-schema.md | 52 +- .../Entities/ApiKey.cs | 67 + .../20260731162342_AddApiKeyTable.Designer.cs | 1618 +++++++++++++++++ .../20260731162342_AddApiKeyTable.cs | 53 + .../OtOpcUaConfigDbContextModelSnapshot.cs | 58 + .../OtOpcUaConfigDbContext.cs | 27 + .../Authorization/ApiKeySecret.cs | 129 ++ .../ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj | 6 + .../SchemaComplianceTests.cs | 4 +- .../Authorization/ApiKeySecretTests.cs | 118 ++ 10 files changed, 2125 insertions(+), 7 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ApiKey.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.Designer.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeySecret.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeySecretTests.cs diff --git a/docs/v2/config-db-schema.md b/docs/v2/config-db-schema.md index 216a1512..01bfde17 100644 --- a/docs/v2/config-db-schema.md +++ b/docs/v2/config-db-schema.md @@ -472,13 +472,19 @@ All five are exposed as **OPC UA properties** on the equipment node so external ### `NodeAcl` +> ⚠️ **Pre-existing drift, noted 2026-07-31 and NOT fixed here:** the block below still shows the +> `GenerationId` column and its `ConfigGeneration` FK. v3 made the config tables single live-state and +> dropped generation versioning (see `sp_ComputeGenerationDiff`, which now ignores its generation +> parameters), so the real table has no such column and the index names differ accordingly. Only the +> `LdapGroup` → `Principal` rename below is current. + ```sql CREATE TABLE dbo.NodeAcl ( NodeAclRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(), GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId), NodeAclId nvarchar(64) NOT NULL, -- stable logical ID across generations ClusterId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId), - LdapGroup nvarchar(256) NOT NULL, + Principal nvarchar(256) NOT NULL, -- API-key principal, or the reserved 'anonymous' (#520) ScopeKind nvarchar(16) NOT NULL CHECK (ScopeKind IN ('Cluster', 'Namespace', 'UnsArea', 'UnsLine', 'Equipment', 'Tag')), ScopeId nvarchar(64) NULL, -- NULL when ScopeKind='Cluster'; logical ID otherwise PermissionFlags int NOT NULL, -- bitmask of NodePermissions enum @@ -487,17 +493,51 @@ CREATE TABLE dbo.NodeAcl ( CREATE INDEX IX_NodeAcl_Generation_Cluster ON dbo.NodeAcl (GenerationId, ClusterId); -CREATE INDEX IX_NodeAcl_Generation_Group - ON dbo.NodeAcl (GenerationId, LdapGroup); +CREATE INDEX IX_NodeAcl_Generation_Principal + ON dbo.NodeAcl (GenerationId, Principal); CREATE INDEX IX_NodeAcl_Generation_Scope ON dbo.NodeAcl (GenerationId, ScopeKind, ScopeId) WHERE ScopeId IS NOT NULL; CREATE UNIQUE INDEX UX_NodeAcl_Generation_LogicalId ON dbo.NodeAcl (GenerationId, NodeAclId); --- Within a generation, a (Group, Scope) pair has at most one row -CREATE UNIQUE INDEX UX_NodeAcl_Generation_GroupScope - ON dbo.NodeAcl (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId); +-- Within a generation, a (Principal, Scope) pair has at most one row +CREATE UNIQUE INDEX UX_NodeAcl_Generation_PrincipalScope + ON dbo.NodeAcl (GenerationId, ClusterId, Principal, ScopeKind, ScopeId); ``` +### `ApiKey` + +OPC UA data-plane credentials (#520). The data plane is machine-to-machine, so a consuming system +authenticates with a key rather than an LDAP user; the key resolves to a `Principal` that `NodeAcl` +rows grant permissions to. One principal per key — two keys may name the same principal, which is +what lets a key be rotated without touching an ACL row. + +```sql +CREATE TABLE dbo.ApiKey ( + ApiKeyRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(), + ApiKeyId nvarchar(64) NOT NULL, -- public id; middle segment of otua__ + Name nvarchar(128) NOT NULL, -- human label, e.g. the consuming system + SecretHash nvarchar(128) NOT NULL, -- base64 peppered HMAC-SHA256; NEVER the secret + Principal nvarchar(256) NOT NULL, + Enabled bit NOT NULL, + CreatedUtc datetime2 NOT NULL, + ExpiresUtc datetime2 NULL, + Notes nvarchar(512) NULL, + RowVersion rowversion NOT NULL +); + +-- The presented key carries ApiKeyId in the clear precisely so verification is this seek. +CREATE UNIQUE INDEX UX_ApiKey_ApiKeyId ON dbo.ApiKey (ApiKeyId); +CREATE INDEX IX_ApiKey_Principal ON dbo.ApiKey (Principal); +``` + +`ApiKeyId` is `NOT NULL` here, deliberately unlike the nullable `NodeAclId` precedent: that shape +permits two NULL ids under a filtered unique index, which is acceptable for a config row and not for +a credentials lookup key. + +⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no +ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing +`Enabled` takes effect on a node only once a new artifact reaches it. + `NodeAcl` is **generation-versioned** (decision #130). ACL changes go through draft → diff → publish → rollback like every other content table. Cross-generation invariant: `NodeAclId` once published with `(LdapGroup, ScopeKind, ScopeId)` cannot have any of those columns change in a future generation; rename an LDAP group by disabling the old grant and creating a new one. `PermissionFlags` is a bitmask of the `NodePermissions` enum defined in `acl-design.md` (Browse, Read, Subscribe, HistoryRead, WriteOperate, WriteTune, WriteConfigure, AlarmRead, AlarmAcknowledge, AlarmConfirm, AlarmShelve, MethodCall). Common bundles (`ReadOnly`, `Operator`, `Engineer`, `Admin`) expand to specific flag combinations at evaluation time. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ApiKey.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ApiKey.cs new file mode 100644 index 00000000..b7c32a2f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ApiKey.cs @@ -0,0 +1,67 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine (#520), so a +/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a +/// that rows grant permissions to. +/// +/// +/// +/// One principal per key, deliberately. The reason to separate keys from principals is so +/// that many keys can share one grant set and rotating a key never touches an ACL row — and that +/// holds with a single principal per key, because nothing stops two keys naming the same +/// principal. A key belonging to several principals at once (union of grants) buys little and +/// costs a join table, so it is not modelled. UserAuthorizationState.Principals is still a +/// list, so adding it later needs no change on the ACL side. +/// +/// +/// The secret is never stored. holds a peppered HMAC-SHA256 of the +/// secret half only; the plaintext is shown once at creation and is unrecoverable afterwards. +/// +/// +/// ⚠️ Revocation requires a deploy. Phase 4 leaves driver-only nodes with no ConfigDb, so +/// keys reach them inside the deployment artifact exactly as rows do. +/// Setting to false takes effect on a node only once a new artifact reaches +/// it. This is an operational sharp edge, not an oversight — see the #520 design doc. +/// +/// +public sealed class ApiKey +{ + /// Gets or sets the database row ID for this key. + public Guid ApiKeyRowId { get; set; } + + /// + /// Gets or sets the public key id — the middle segment of otua_<keyId>_<secret>. + /// Carried in the clear so verification is an index seek rather than a scan over every stored hash. + /// + public required string ApiKeyId { get; set; } + + /// Gets or sets the human-readable label, e.g. the consuming system's name. + public required string Name { get; set; } + + /// + /// Gets or sets the base64 peppered HMAC-SHA256 of the key's secret segment. Never the secret. + /// + public required string SecretHash { get; set; } + + /// + /// Gets or sets the principal this key authenticates as. rows + /// naming this value grant it permissions. + /// + public required string Principal { get; set; } + + /// Gets or sets whether the key may authenticate. See the revocation caveat on the type. + public bool Enabled { get; set; } = true; + + /// Gets or sets when the key was created (UTC). + public DateTime CreatedUtc { get; set; } + + /// Gets or sets when the key expires (UTC); null never expires. + public DateTime? ExpiresUtc { get; set; } + + /// Gets or sets optional operator notes. + public string? Notes { get; set; } + + /// Optimistic concurrency token, matching the rest of the live-edit config model. + public byte[] RowVersion { get; set; } = Array.Empty(); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.Designer.cs new file mode 100644 index 00000000..6dfef45b --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.Designer.cs @@ -0,0 +1,1618 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260731162342_AddApiKeyTable")] + partial class AddApiKeyTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey", b => + { + b.Property("ApiKeyRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ApiKeyId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("ExpiresUtc") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("ApiKeyRowId"); + + b.HasIndex("ApiKeyId") + .IsUnique() + .HasDatabaseName("UX_ApiKey_ApiKeyId"); + + b.HasIndex("Principal") + .HasDatabaseName("IX_ApiKey_Principal"); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AkkaPort") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4053); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GrpcPort") + .HasColumnType("int"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("MaintenanceMode") + .HasColumnType("bit"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Outcome") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); + + b.HasIndex("DriverInstanceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Device_Driver_Name"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("RawFolderId") + .HasDatabaseName("IX_DriverInstance_RawFolder"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_ClusterRoot_Name") + .HasFilter("[RawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "RawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Folder_Name") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("Principal") + .HasDatabaseName("IX_NodeAcl_Principal"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Principal", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_PrincipalScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b => + { + b.Property("RawFolderRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentRawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("RawFolderRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_RawFolder_Cluster"); + + b.HasIndex("RawFolderId") + .IsUnique() + .HasDatabaseName("UX_RawFolder_LogicalId") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_ClusterRoot_Name") + .HasFilter("[ParentRawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "ParentRawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_Parent_Name") + .HasFilter("[ParentRawFolderId] IS NOT NULL"); + + b.ToTable("RawFolder", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Device_Name") + .HasFilter("[TagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "TagGroupId") + .HasDatabaseName("IX_Tag_Device"); + + b.HasIndex("DeviceId", "TagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Group_Name") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b => + { + b.Property("TagGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentTagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("TagGroupRowId"); + + b.HasIndex("DeviceId") + .HasDatabaseName("IX_TagGroup_Device"); + + b.HasIndex("TagGroupId") + .IsUnique() + .HasDatabaseName("UX_TagGroup_LogicalId") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_DeviceRoot_Name") + .HasFilter("[ParentTagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "ParentTagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_Parent_Name") + .HasFilter("[ParentTagGroupId] IS NOT NULL"); + + b.ToTable("TagGroup", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b => + { + b.Property("UnsTagReferenceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DisplayNameOverride") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsTagReferenceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsTagReferenceRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_UnsTagReference_Equipment"); + + b.HasIndex("TagId") + .HasDatabaseName("IX_UnsTagReference_Tag"); + + b.HasIndex("UnsTagReferenceId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_LogicalId") + .HasFilter("[UnsTagReferenceId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "TagId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_Equip_Tag"); + + b.ToTable("UnsTagReference", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null) + .WithMany("RawFolders") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Nodes"); + + b.Navigation("RawFolders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.cs new file mode 100644 index 00000000..f5ef3f2f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260731162342_AddApiKeyTable.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class AddApiKeyTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKey", + columns: table => new + { + ApiKeyRowId = table.Column(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + ApiKeyId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + SecretHash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Principal = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + CreatedUtc = table.Column(type: "datetime2", nullable: false), + ExpiresUtc = table.Column(type: "datetime2", nullable: true), + Notes = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + RowVersion = table.Column(type: "rowversion", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKey", x => x.ApiKeyRowId); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKey_Principal", + table: "ApiKey", + column: "Principal"); + + migrationBuilder.CreateIndex( + name: "UX_ApiKey_ApiKeyId", + table: "ApiKey", + column: "ApiKeyId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKey"); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index dc0cab3c..ccb372d7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -41,6 +41,64 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.ToTable("DataProtectionKeys", (string)null); }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey", b => + { + b.Property("ApiKeyRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ApiKeyId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedUtc") + .HasColumnType("datetime2"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("ExpiresUtc") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("ApiKeyRowId"); + + b.HasIndex("ApiKeyId") + .IsUnique() + .HasDatabaseName("UX_ApiKey_ApiKeyId"); + + b.HasIndex("Principal") + .HasDatabaseName("IX_ApiKey_Principal"); + + b.ToTable("ApiKey", (string)null); + }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => { b.Property("NodeId") diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index 0ca47c20..166345fb 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -40,6 +40,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions PollGroups => Set(); /// Gets the DbSet of node ACLs. public DbSet NodeAcls => Set(); + + /// OPC UA data-plane API keys (#520). One principal per key; secret stored as a peppered hash. + public DbSet ApiKeys => Set(); /// Gets the DbSet of configuration audit logs. public DbSet ConfigAuditLogs => Set(); /// Gets the DbSet of external ID reservations. @@ -84,6 +87,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions(e => + { + e.ToTable("ApiKey"); + e.HasKey(x => x.ApiKeyRowId); + e.Property(x => x.ApiKeyRowId).HasDefaultValueSql("NEWSEQUENTIALID()"); + // IsRequired() deliberately deviates from the NodeAclId precedent, which is a nullable column + // with a filtered unique index. That shape lets two NULL ids coexist; harmless for a config + // row, not something to carry into a credentials table where the id is the lookup key. + e.Property(x => x.ApiKeyId).HasMaxLength(64).IsRequired(); + e.Property(x => x.Name).HasMaxLength(128); + e.Property(x => x.SecretHash).HasMaxLength(128); + e.Property(x => x.Principal).HasMaxLength(256); + e.Property(x => x.Notes).HasMaxLength(512); + e.Property(x => x.RowVersion).IsRowVersion(); + + // The presented key carries ApiKeyId in the clear precisely so verification is this seek. + e.HasIndex(x => x.ApiKeyId).IsUnique().HasDatabaseName("UX_ApiKey_ApiKeyId"); + e.HasIndex(x => x.Principal).HasDatabaseName("IX_ApiKey_Principal"); + }); + } + private static void ConfigureNodeAcl(ModelBuilder modelBuilder) { modelBuilder.Entity(e => diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeySecret.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeySecret.cs new file mode 100644 index 00000000..32d23c0e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeySecret.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// +/// Pure helpers for the OPC UA data-plane API key format otua_<keyId>_<secret> +/// (#520): generation, parsing, and constant-time verification. No I/O and no DI, so the +/// security-critical half is unit-testable on its own. +/// +/// +/// +/// The format mirrors the family convention — histgw_<id>_<secret> in +/// HistorianGateway, apikey create-key in mxaccessgw. The keyId travels in the clear +/// so a lookup is an index seek; only the secret half is hashed. +/// +/// +/// Why a plain HMAC and not PBKDF2/Argon2. Slow password hashing exists to make guessing a +/// low-entropy human secret expensive. A key secret here is bytes of +/// CSPRNG output, so guessing is already infeasible and an iterated KDF would only add latency to +/// a call that runs inline on OPC UA session activation — which the SDK serializes server-wide +/// behind its SessionManager event lock. The pepper is what defends a leaked database: an +/// attacker with the table but not the pepper cannot verify a guess offline. +/// +/// +/// ⚠️ An unset pepper fails closed. throws rather than hashing +/// under an empty key, so a deployment that forgets to configure one cannot silently accept keys +/// whose hashes were all computed under the same empty pepper. +/// +/// +public static class ApiKeySecret +{ + /// The literal prefix identifying an OtOpcUa data-plane key. + public const string Prefix = "otua"; + + /// Bytes of CSPRNG output behind the public key id. + public const int KeyIdBytes = 8; + + /// Bytes of CSPRNG output behind the secret segment. + public const int SecretBytes = 32; + + /// + /// Generates a new key. The returned Presented value is the only time the secret exists + /// outside the caller — it is not recoverable from SecretHash. + /// + /// Server-side pepper; must be non-empty. + /// The presented key string, its public key id, and the hash to persist. + public static (string Presented, string ApiKeyId, string SecretHash) Generate(string pepper) + { + // The key id is HEX, not url-safe base64: that alphabet maps '/' to '_', so a base64 key id could + // contain the very character TryParse splits on and would be truncated at a random position. The + // secret is the whole remainder after the second '_', so underscores there are harmless. + var keyId = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(KeyIdBytes)); + var secret = ToUrlSafeBase64(RandomNumberGenerator.GetBytes(SecretBytes)); + return ($"{Prefix}_{keyId}_{secret}", keyId, ComputeHash(secret, pepper)); + } + + /// + /// Splits a presented key into its public id and secret halves. + /// + /// + /// Splits on the FIRST two underscores only, so a secret that itself contains an underscore round + /// trips. Returns false rather than throwing — a malformed credential is an ordinary + /// authentication failure, not an exceptional condition, and it arrives from the network. + /// + /// The presented key string. + /// The parsed public key id. + /// The parsed secret segment. + /// when the value is well-formed. + public static bool TryParse(string? presented, out string apiKeyId, out string secret) + { + apiKeyId = string.Empty; + secret = string.Empty; + if (string.IsNullOrEmpty(presented)) return false; + + var first = presented.IndexOf('_'); + if (first <= 0) return false; + if (!presented.AsSpan(0, first).SequenceEqual(Prefix)) return false; + + var second = presented.IndexOf('_', first + 1); + if (second < 0) return false; + + apiKeyId = presented[(first + 1)..second]; + secret = presented[(second + 1)..]; + return apiKeyId.Length > 0 && secret.Length > 0; + } + + /// Computes the peppered HMAC-SHA256 of a secret segment, base64-encoded. + /// The secret segment. + /// Server-side pepper; must be non-empty. + /// The base64 hash. + /// The pepper is null, empty, or whitespace. + public static string ComputeHash(string secret, string pepper) + { + if (string.IsNullOrWhiteSpace(pepper)) + { + // Fail closed. Hashing under an empty pepper would "work" and produce a stable value, so a + // misconfigured deployment would authenticate normally while offering no protection at all. + throw new ArgumentException( + "An API key pepper must be configured (Security:ApiKeys:Pepper). Refusing to hash under an empty pepper.", + nameof(pepper)); + } + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(pepper)); + return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(secret))); + } + + /// + /// Constant-time comparison of a presented secret against a stored hash. + /// + /// The presented secret segment. + /// Server-side pepper. + /// The persisted base64 hash. + /// when they match. + public static bool Verify(string secret, string pepper, string? storedHash) + { + if (string.IsNullOrEmpty(storedHash)) return false; + + byte[] expected; + try { expected = Convert.FromBase64String(storedHash); } + catch (FormatException) { return false; } + + var actual = Convert.FromBase64String(ComputeHash(secret, pepper)); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + private static string ToUrlSafeBase64(byte[] bytes) => + Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj index e401905e..72b787cc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj @@ -19,6 +19,12 @@ + + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs index 6f38a15b..ce8df0b0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs @@ -32,7 +32,9 @@ public sealed class SchemaComplianceTests // v3 Raw-tree tables (Namespace retired; RawFolder/TagGroup/UnsTagReference added) "RawFolder", "TagGroup", "UnsTagReference", "DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag", - "NodeAcl", "ExternalIdReservation", + // ApiKey added in Gitea #520 — the OPC UA data plane is machine-to-machine, so a consuming + // system authenticates with a key resolving to a Principal that NodeAcl rows grant to. + "NodeAcl", "ApiKey", "ExternalIdReservation", // DriverHostStatus was dropped in Gitea #521 — nothing ever wrote a row, and per-cluster // mesh Phase 4 made the publisher its entity doc described unbuildable (a driver-only node // has no ConfigDb connection). Per-host connectivity now rides DriverHealthChanged to /hosts. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeySecretTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeySecretTests.cs new file mode 100644 index 00000000..c3a61c9a --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeySecretTests.cs @@ -0,0 +1,118 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization; + +/// +/// Covers the pure API-key format helpers (#520). These are the security-critical half of the +/// data-plane identity change, so they are pinned independently of any store or DI. +/// +public sealed class ApiKeySecretTests +{ + private const string Pepper = "test-pepper-not-a-real-one"; + + [Fact] + public void A_generated_key_round_trips_through_parse_and_verify() + { + var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper); + + ApiKeySecret.TryParse(presented, out var parsedId, out var secret).ShouldBeTrue(); + parsedId.ShouldBe(apiKeyId); + ApiKeySecret.Verify(secret, Pepper, hash).ShouldBeTrue(); + } + + [Fact] + public void The_generated_key_id_never_contains_the_separator() + { + // Regression guard. The secret is url-safe base64, whose alphabet maps '/' to '_'. If the key id + // used that same alphabet it could contain the character TryParse splits on, and the id would be + // silently truncated at a random position for roughly 1 key in 5 — an intermittent auth failure + // that only ever reproduces for SOME keys. Hence hex. 400 draws makes a '_' near-certain if the + // alphabet ever regresses to base64. + for (var i = 0; i < 400; i++) + { + var (_, apiKeyId, _) = ApiKeySecret.Generate(Pepper); + apiKeyId.ShouldNotContain("_", Case.Sensitive, + $"key id '{apiKeyId}' contains the separator — TryParse would truncate it"); + } + } + + [Fact] + public void A_secret_containing_the_separator_still_round_trips() + { + // The secret IS allowed to contain '_' (it is the whole remainder), so prove the split is + // first-two-underscores rather than split-on-every-underscore. + const string secret = "aa_bb_cc__dd"; + var presented = $"otua_deadbeef_{secret}"; + + ApiKeySecret.TryParse(presented, out var id, out var parsed).ShouldBeTrue(); + id.ShouldBe("deadbeef"); + parsed.ShouldBe(secret); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("otua")] + [InlineData("otua_")] + [InlineData("otua_onlyid")] + [InlineData("_noprefix_secret")] + [InlineData("wrong_id_secret")] + [InlineData("otua__emptyid")] + public void A_malformed_key_is_rejected_without_throwing(string? presented) + { + // These arrive from the network, so malformed input must be an ordinary auth failure. + ApiKeySecret.TryParse(presented, out _, out _).ShouldBeFalse(); + } + + [Fact] + public void A_wrong_secret_does_not_verify() + { + var (_, _, hash) = ApiKeySecret.Generate(Pepper); + ApiKeySecret.Verify("not-the-secret", Pepper, hash).ShouldBeFalse(); + } + + [Fact] + public void The_same_secret_under_a_different_pepper_does_not_verify() + { + // This is what the pepper buys: a leaked ApiKey table cannot be verified against offline. + var (presented, _, hash) = ApiKeySecret.Generate(Pepper); + ApiKeySecret.TryParse(presented, out _, out var secret).ShouldBeTrue(); + + ApiKeySecret.Verify(secret, "a-different-pepper", hash).ShouldBeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Hashing_under_an_absent_pepper_throws_rather_than_failing_open(string? pepper) + { + // Fail closed. An empty pepper would still produce a stable hash, so a misconfigured deployment + // would authenticate normally while offering none of the protection it believes it has. + Should.Throw(() => ApiKeySecret.ComputeHash("secret", pepper!)); + } + + [Fact] + public void A_non_base64_stored_hash_is_rejected_rather_than_throwing() + { + // A corrupted or hand-edited row must deny, not crash session activation for everyone. + ApiKeySecret.Verify("secret", Pepper, "!!!not base64!!!").ShouldBeFalse(); + ApiKeySecret.Verify("secret", Pepper, null).ShouldBeFalse(); + } + + [Fact] + public void Two_generated_keys_never_collide() + { + var ids = new HashSet(StringComparer.Ordinal); + var secrets = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < 200; i++) + { + var (presented, apiKeyId, _) = ApiKeySecret.Generate(Pepper); + ApiKeySecret.TryParse(presented, out _, out var secret).ShouldBeTrue(); + ids.Add(apiKeyId).ShouldBeTrue($"duplicate key id '{apiKeyId}'"); + secrets.Add(secret).ShouldBeTrue("duplicate secret"); + } + } +} From 0329fa4d4bc1dc226c9fe22d1658835fa848bc6a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 12:57:51 -0400 Subject: [PATCH 04/10] feat(acl): API-key identity replaces LDAP on the OPC UA data plane (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2a. Clients present the static username "apikey" with an otua__ credential as the password; the key resolves to a principal. LDAP registrations are deliberately RETAINED for AdminUI login — only IOpcUaUserAuthenticator moves, which is what D2 asked for. The load-bearing decision here is what roles a key gets. The two live gates (WriteOperate on Value writes, AlarmAck on Part 9 methods) check a SERVER-WIDE role set, so the cutover has to keep producing one or it would lock every client out of writes long before per-node evaluation lands in stage 5. ApiKeyInterimRoleMapper therefore maps ACL grants to roles, but ONLY cluster-scoped ones. The obvious version — union the permission flags across all of a principal's grants — would let a grant authored narrowly ("WriteOperate on Equipment X") confer server-wide write authority, because nothing in the pipeline can enforce scope yet. That fails in the dangerous direction: the operator's narrow intent silently becomes broad. Restricting to cluster scope under-grants instead, which is no worse than today (where every ACL row does nothing) and can never exceed what was authored. Falsified: deleting the scope check reds exactly A_narrower_scoped_write_grant_confers_nothing_yet and nothing else. Verification is one indexed lookup plus one HMAC, with no network I/O at all. That matters because the SDK raises ImpersonateUser synchronously while holding a SessionManager-wide event lock, so this path does not stall one request — it serializes every session activation server-wide. It is the main practical gain over the LDAP bind it replaces. Every rejection returns one opaque message so the server cannot be used as an oracle for whether a key id exists or which half of a credential was wrong; a test asserts the failure modes really are indistinguishable AND that a valid key still succeeds, so it cannot pass by denying everything. Caught while wiring: OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED ctor parameter, so leaving a driver-only node unregistered is not a quiet fallback to deny-all — it fails DI and the node does not boot. NullOpcUaUserAuthenticator is now registered explicitly there. A driver-only node has no ConfigDb since Phase 4 and so has no key source until the artifact-backed store lands in stage 4; it denies UserName tokens until then, which is the safe direction, and anonymous sessions are unaffected. LdapOpcUaUserAuthenticator is now registered nowhere and is dead code. Deleting it plus LdapOpcUaUserAuthenticatorTests / LdapAuthResilienceTests / LdapGlauthOutageLiveTests is stage 2b, kept separate because those cover a live-gated resilience feature (hard wall-clock bound, bounded concurrency, directory-outage circuit) that exists precisely BECAUSE the LDAP bind ran inline on session activation — a constraint the key path does not have. That deserves its own commit, not a footnote in this one. Anonymous-as-a-grantable-principal (D3) is also stage 2b. Host 37, Security 89, OpcUaServer 364 green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- .../OpcUa/ApiKeyInterimRoleMapper.cs | 67 ++++++ .../OpcUa/ApiKeyOpcUaUserAuthenticator.cs | 131 +++++++++++ .../OpcUa/ApiKeyOptions.cs | 30 +++ .../OpcUa/ConfigDbApiKeyStore.cs | 57 +++++ .../OpcUa/IApiKeyStore.cs | 30 +++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 23 +- .../OpcUa/ApiKeyAuthenticationTests.cs | 221 ++++++++++++++++++ 7 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs new file mode 100644 index 00000000..0fa648d6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyInterimRoleMapper.cs @@ -0,0 +1,67 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Derives the interim server-wide role set for an authenticated principal from its +/// grants (#520, stage 2). +/// +/// +/// +/// Why this exists at all. The two live authorization gates — +/// on Value writes and +/// on the Part 9 alarm methods — check a server-wide +/// role set on the session identity. Replacing LDAP with API keys therefore has to keep +/// producing that role set, or the cutover would lock every client out of writes and alarm acks +/// long before per-node ACL evaluation lands in stage 5. +/// +/// +/// ⚠️ Only grants confer a role. This is deliberate, +/// and it under-grants. The obvious implementation — union the permission flags across all of +/// a principal's grants — would let a grant authored narrowly ("WriteOperate on Equipment X") +/// confer the server-wide WriteOperate role, because nothing here can enforce scope yet. That +/// fails in the dangerous direction: the operator's narrow intent silently becomes broad +/// authority. Restricting to cluster-scoped grants means a narrow grant does nothing until +/// stage 5 can honour its scope, which is no worse than today (where every ACL row does +/// nothing) and never exceeds what was authored. +/// +/// +/// Scope-narrower-than-cluster grants are not an error and are not logged as one — they are +/// simply inert until enforcement lands. The AdminUI banner is where an operator is told that. +/// +/// +public static class ApiKeyInterimRoleMapper +{ + /// + /// Maps a principal's cluster-scoped grants to the server-wide data-plane roles. + /// + /// Candidate ACL rows. Rows for other principals are ignored. + /// The authenticated principal. + /// The distinct role names to attach to the session identity; never null. + public static IReadOnlyList Map(IEnumerable grants, string principal) + { + ArgumentNullException.ThrowIfNull(grants); + + if (string.IsNullOrWhiteSpace(principal)) return Array.Empty(); + + var effective = NodePermissions.None; + foreach (var grant in grants) + { + if (grant.ScopeKind != NodeAclScopeKind.Cluster) continue; + if (!string.Equals(grant.Principal, principal, StringComparison.OrdinalIgnoreCase)) continue; + effective |= grant.PermissionFlags; + } + + var roles = new List(2); + if (effective.HasFlag(NodePermissions.WriteOperate)) roles.Add(OpcUaDataPlaneRoles.WriteOperate); + + // AlarmAcknowledge is the flag an operator authors; AlarmAck is the role the Part 9 method gates + // check. The gate covers acknowledge/confirm/shelve as one role, so Confirm/Shelve do not map + // separately — mapping them would imply a granularity the gate does not have. + if (effective.HasFlag(NodePermissions.AlarmAcknowledge)) roles.Add(OpcUaDataPlaneRoles.AlarmAck); + + return roles; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs new file mode 100644 index 00000000..0415cc16 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs @@ -0,0 +1,131 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Production for the machine-to-machine data plane (#520): +/// the client presents the static username apikey with an +/// otua_<keyId>_<secret> credential as the password. Replaces the LDAP adapter. +/// +/// +/// +/// This runs inline on session activation. The SDK raises ImpersonateUser +/// synchronously while holding a SessionManager-wide event lock, so a slow authenticator does not +/// stall one request — it serializes every session activation server-wide. That is why +/// verification is a single indexed lookup plus one HMAC, and why there is no directory call, +/// no retry, and no network I/O on this path at all. It is the main practical advantage of keys +/// over the LDAP bind this replaces. +/// +/// +/// Fail closed, and say little. Every rejection returns the same opaque message. The +/// reason is logged server-side but never returned, so a caller cannot distinguish "no such key" +/// from "wrong secret" from "key disabled" and use the server as an oracle. +/// +/// +public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator +{ + /// + /// Returned for every failure mode, deliberately uninformative. Public so a test can assert that + /// the failure modes really are indistinguishable, rather than restating the literal and passing + /// even if the implementation started differentiating them. + /// + public const string DenyMessage = "Invalid API key."; + + private readonly IApiKeyStore _store; + private readonly IOptions _options; + private readonly ILogger _log; + private readonly TimeProvider _time; + + /// Initializes a new instance of the class. + /// The API key source. + /// The Security:ApiKeys options. + /// The logger. + /// Time provider, injected so expiry is testable. + public ApiKeyOpcUaUserAuthenticator( + IApiKeyStore store, + IOptions options, + ILogger log, + TimeProvider? time = null) + { + _store = store; + _options = options; + _log = log; + _time = time ?? TimeProvider.System; + } + + /// + public async Task AuthenticateUserNameAsync( + string username, string password, CancellationToken ct) + { + var opts = _options.Value; + + // The username carries no information, but check it rather than ignore it: a client sending the + // wrong username gets an explicit denial it can diagnose, instead of "any username works". + if (!string.Equals(username, opts.Username, StringComparison.Ordinal)) + { + _log.LogInformation( + "ApiKeyAuth: denied — username {Presented} is not the configured {Expected}", + username, opts.Username); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + if (!ApiKeySecret.TryParse(password, out var apiKeyId, out var secret)) + { + _log.LogInformation("ApiKeyAuth: denied — credential is not a well-formed otua_ key"); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + var now = _time.GetUtcNow().UtcDateTime; + + ApiKeyLookup? lookup; + try + { + lookup = await _store.FindAsync(apiKeyId, now, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // The contract forbids throwing: an exception here escapes into the SDK's session-activation + // path and surfaces as BadInternalError. A store outage denies, it does not crash. + _log.LogError(ex, "ApiKeyAuth: key store threw for key id {ApiKeyId}", apiKeyId); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + if (lookup is null) + { + _log.LogInformation("ApiKeyAuth: denied — no enabled, unexpired key with id {ApiKeyId}", apiKeyId); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + bool verified; + try + { + verified = ApiKeySecret.Verify(secret, opts.Pepper, lookup.Key.SecretHash); + } + catch (ArgumentException ex) + { + // Thrown only when the pepper is unset. This is a deployment error, not a bad credential — + // log it loudly, because otherwise it presents as "every key is rejected" with no clue why. + _log.LogError(ex, + "ApiKeyAuth: {Section}:Pepper is not configured — every key will be rejected until it is", + ApiKeyOptions.SectionName); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + if (!verified) + { + _log.LogInformation("ApiKeyAuth: denied — secret mismatch for key id {ApiKeyId}", apiKeyId); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + + var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal); + + _log.LogInformation( + "ApiKeyAuth: granted key {Name} ({ApiKeyId}) as principal {Principal} with roles [{Roles}]", + lookup.Key.Name, apiKeyId, lookup.Key.Principal, string.Join(",", roles)); + + return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs new file mode 100644 index 00000000..d6e384bf --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Security:ApiKeys — OPC UA data-plane API key settings (#520). The data plane is +/// machine-to-machine, so a consuming system presents a key rather than an LDAP user. +/// +public sealed class ApiKeyOptions +{ + /// The configuration section name. + public const string SectionName = "Security:ApiKeys"; + + /// + /// The static username every key-bearing client presents. The username carries no information — + /// the key identifies the principal — but OPC UA's UserName token requires one, so it is fixed + /// and checked rather than ignored, which keeps a typo an explicit denial instead of a silent + /// "any username works". + /// + public string Username { get; set; } = "apikey"; + + /// + /// Server-side pepper for the stored key hashes. Supply via Security__ApiKeys__Pepper or a + /// ${secret:} reference — never commit it. Rotating it invalidates every existing key. + /// + /// + /// Deliberately has no default. An empty pepper still hashes "successfully" and would produce a + /// working-looking deployment with none of the protection it believes it has, so + /// ApiKeySecret.ComputeHash throws on one and this stays empty to make that fire early. + /// + public string Pepper { get; set; } = string.Empty; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs new file mode 100644 index 00000000..f268f6f6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// reading the config database directly (#520, stage 2). Usable on a node +/// that has a ConfigDb — i.e. one carrying the admin role, including a fused admin+driver node. +/// +/// +/// ⚠️ A driver-only node has no ConfigDb since per-cluster mesh Phase 4, so this store cannot +/// be registered there and such a node has no key source until the artifact-backed store lands in +/// stage 4. Until then a driver-only node authenticates no UserName tokens at all — it falls to +/// , which denies every attempt. That is +/// the safe direction, and anonymous sessions (still permitted, and still unrestricted until +/// enforcement lands in stages 5–6) are unaffected. +/// +public sealed class ConfigDbApiKeyStore : IApiKeyStore +{ + private readonly IDbContextFactory _factory; + + /// Initializes a new instance of the class. + /// The config DB context factory. + public ConfigDbApiKeyStore(IDbContextFactory factory) => _factory = factory; + + /// + public async Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + + // Enabled + expiry are filtered in SQL rather than in memory so a disabled key never + // materialises into a process where something might later treat it as a match. + var key = await db.ApiKeys + .AsNoTracking() + .SingleOrDefaultAsync( + k => k.ApiKeyId == apiKeyId + && k.Enabled + && (k.ExpiresUtc == null || k.ExpiresUtc > utcNow), + ct) + .ConfigureAwait(false); + + if (key is null) return null; + + // Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only + // those are fetched. Stage 5 widens this to the full grant set for per-node evaluation. + var grants = await db.NodeAcls + .AsNoTracking() + .Where(a => a.Principal == key.Principal + && a.ScopeKind == NodeAclScopeKind.Cluster) + .ToListAsync(ct) + .ConfigureAwait(false); + + return new ApiKeyLookup(key, grants); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs new file mode 100644 index 00000000..67cfb2c1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs @@ -0,0 +1,30 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// Source of API keys and their ACL grants for OPC UA session activation (#520). +/// +/// +/// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused +/// admin+driver node reads the config database directly; a driver-only node has no ConfigDb +/// at all since per-cluster mesh Phase 4, so its keys must come out of the deployment artifact. +/// Stage 2 ships the ConfigDb implementation; the artifact-backed one lands in stage 4. +/// +public interface IApiKeyStore +{ + /// + /// Looks up an enabled, unexpired key by its public id, together with the ACL grants naming that + /// key's principal. + /// + /// The public key id parsed from the presented credential. + /// Current UTC time, for the expiry check. + /// The cancellation token. + /// The key and its principal's grants, or null when no usable key matches. + Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct); +} + +/// An API key resolved for authentication, with the ACL grants naming its principal. +/// The matched key row. +/// ACL rows whose principal matches . +public sealed record ApiKeyLookup(ApiKey Key, IReadOnlyList Grants); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 51380bb8..2efba25b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -339,7 +339,28 @@ if (hasDriver) builder.Services.TryAddSingleton(); builder.Services.TryAddScoped(); builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>(); - builder.Services.AddSingleton(); + + // #520 stage 2 — the OPC UA DATA PLANE is machine-to-machine, so its identity is an API key + // (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP + // registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP. + // Only IOpcUaUserAuthenticator moves. + builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); + if (hasAdmin) + { + // ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin. + // A driver-only node therefore has no key source until the artifact-backed store lands in + // stage 4; it falls through to NullOpcUaUserAuthenticator, which denies every UserName token. + // Safe direction, and anonymous sessions are unaffected. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + else + { + // MUST be registered explicitly, not left absent: OtOpcUaServerHostedService takes + // IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so an unregistered seam is not a + // quiet fallback to deny-all — it fails DI and the node does not start at all. + builder.Services.AddSingleton(NullOpcUaUserAuthenticator.Instance); + } // 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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs new file mode 100644 index 00000000..767ebacc --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs @@ -0,0 +1,221 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa; + +/// +/// Covers the #520 stage-2 identity cutover: the API-key authenticator and the interim role +/// mapping it feeds the two live authorization gates from. +/// +public sealed class ApiKeyAuthenticationTests +{ + private const string Pepper = "unit-test-pepper"; + private const string Principal = "scadabridge-dcl"; + + // ---------- interim role mapping ---------- + + [Fact] + public void A_cluster_scoped_write_grant_confers_the_write_role() + { + var roles = ApiKeyInterimRoleMapper.Map( + [Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal); + + roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + } + + [Fact] + public void A_narrower_scoped_write_grant_confers_nothing_yet() + { + // THE load-bearing assertion of this stage. Nothing in the pipeline can honour a scope until + // stage 5, so honouring the FLAG alone would turn "WriteOperate on Equipment X" into + // server-wide write authority — the operator's narrow intent silently becoming broad. Under- + // granting is the only safe reading while scope is unenforceable. + foreach (var narrower in new[] + { + NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea, + NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace, + }) + { + var roles = ApiKeyInterimRoleMapper.Map( + [Grant(narrower, NodePermissions.WriteOperate)], Principal); + + roles.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role"); + } + } + + [Fact] + public void A_grant_for_a_different_principal_is_ignored() + { + var other = Grant(NodeAclScopeKind.Cluster, NodePermissions.Admin); + other.Principal = "someone-else"; + + ApiKeyInterimRoleMapper.Map([other], Principal).ShouldBeEmpty(); + } + + [Fact] + public void Alarm_acknowledge_maps_to_the_alarm_role_and_read_only_maps_to_nothing() + { + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge)], Principal) + .ShouldContain(OpcUaDataPlaneRoles.AlarmAck); + + // ReadOnly carries no gated permission — reads are ungated today, so it must confer no role + // rather than quietly granting one. + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.ReadOnly)], Principal) + .ShouldBeEmpty(); + } + + [Fact] + public void Flags_union_across_several_cluster_grants() + { + var roles = ApiKeyInterimRoleMapper.Map( + [ + Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate), + Grant(NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge), + ], Principal); + + roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + roles.ShouldContain(OpcUaDataPlaneRoles.AlarmAck); + } + + [Fact] + public void Principal_matching_is_case_insensitive_but_not_prefix_based() + { + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal.ToUpperInvariant()) + .ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + + // A principal that merely starts with the granted name must not inherit it. + ApiKeyInterimRoleMapper + .Map([Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate)], Principal + "-readonly") + .ShouldBeEmpty(); + } + + // ---------- authenticator ---------- + + [Fact] + public async Task A_valid_key_authenticates_as_its_principal() + { + var (presented, store) = SetUp(NodePermissions.WriteOperate, NodeAclScopeKind.Cluster); + + var result = await Auth(store).AuthenticateUserNameAsync("apikey", presented, default); + + result.Success.ShouldBeTrue(); + result.DisplayName.ShouldBe(Principal); + result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + } + + [Fact] + public async Task The_wrong_username_is_denied() + { + var (presented, store) = SetUp(); + var result = await Auth(store).AuthenticateUserNameAsync("alice", presented, default); + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task A_wrong_secret_a_missing_key_and_a_malformed_credential_are_all_denied_identically() + { + var (presented, store) = SetUp(); + var auth = Auth(store); + + var wrongSecret = await auth.AuthenticateUserNameAsync("apikey", $"otua_{store.KeyId}_wrong", default); + var noSuchKey = await auth.AuthenticateUserNameAsync("apikey", "otua_ffffffff_whatever", default); + var malformed = await auth.AuthenticateUserNameAsync("apikey", "not-a-key", default); + var empty = await auth.AuthenticateUserNameAsync("apikey", "", default); + + foreach (var r in new[] { wrongSecret, noSuchKey, malformed, empty }) + { + r.Success.ShouldBeFalse(); + // Identical message for every failure mode: the server must not be usable as an oracle for + // which half of the credential was wrong, or whether a key id exists at all. + r.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage); + } + + // ...and the valid one still works, so the above is not passing because everything is denied. + (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeTrue(); + } + + [Fact] + public async Task A_store_that_throws_denies_rather_than_propagating() + { + // The interface contract forbids throwing — an exception escapes into the SDK session-activation + // path and surfaces as BadInternalError instead of a clean rejection. + var store = new ThrowingApiKeyStore(); + var result = await Auth(store).AuthenticateUserNameAsync("apikey", "otua_aabbccdd_secret", default); + + result.Success.ShouldBeFalse(); + result.Error.ShouldBe(ApiKeyOpcUaUserAuthenticator.DenyMessage); + } + + [Fact] + public async Task An_unset_pepper_denies_rather_than_authenticating_everything() + { + var (presented, store) = SetUp(); + var auth = new ApiKeyOpcUaUserAuthenticator( + store, + Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = "" }), + NullLogger.Instance); + + (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse(); + } + + // ---------- helpers ---------- + + private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new() + { + NodeAclId = $"ACL-{scope}-{flags}", + ClusterId = "MAIN", + Principal = Principal, + ScopeKind = scope, + ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE", + PermissionFlags = flags, + }; + + private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new( + store, + Options.Create(new ApiKeyOptions { Username = "apikey", Pepper = Pepper }), + NullLogger.Instance); + + private static (string Presented, FakeApiKeyStore Store) SetUp( + NodePermissions flags = NodePermissions.None, + NodeAclScopeKind scope = NodeAclScopeKind.Cluster) + { + var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper); + var key = new ApiKey + { + ApiKeyId = apiKeyId, + Name = "test key", + SecretHash = hash, + Principal = Principal, + Enabled = true, + CreatedUtc = DateTime.UtcNow, + }; + var grants = flags == NodePermissions.None ? [] : new List { Grant(scope, flags) }; + return (presented, new FakeApiKeyStore(key, grants)); + } + + private sealed class FakeApiKeyStore(ApiKey key, IReadOnlyList grants) : IApiKeyStore + { + public string KeyId => key.ApiKeyId; + + public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => + Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal) + ? new ApiKeyLookup(key, grants) + : null); + } + + private sealed class ThrowingApiKeyStore : IApiKeyStore + { + public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => + throw new InvalidOperationException("store is down"); + } +} From 65fda7237f5437f5cd9276013c2eb842783a4b22 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 13:08:50 -0400 Subject: [PATCH 05/10] refactor(acl): delete the LDAP data-plane authenticator and its tests (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2b. LdapOpcUaUserAuthenticator was left registered nowhere by stage 2a; this removes it, its three test files, and the config surface that existed only to serve it. Deleted: LdapOpcUaUserAuthenticator, LdapOpcUaUserAuthenticatorTests, LdapAuthResilienceTests, LdapGlauthOutageLiveTests (19 tests). Also removed four Security:Ldap keys — AuthTimeoutMs, MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds — plus their validator rules and binding/validator test coverage. Verified first that the ONLY consumers were the deleted class and the validator rules asserting them; none appear in any checked-in appsettings. Leaving them would have created exactly the dead-config surface #521/#524/#525 were filed to clear up. Worth stating why that resilience machinery does not need relocating rather than just deleting. It existed because the OPC UA SDK raises impersonation SYNCHRONOUSLY under a SessionManager-wide event lock, so a hung LDAP bind there serialized every session activation server-wide — Anonymous and X509 included. Key verification is one indexed lookup plus one HMAC with no network I/O, so the hazard the wall-clock bound, concurrency cap and outage circuit guarded against no longer exists on that path. LDAP now runs only on AdminUI login, an ordinary async request path where a slow directory affects the one request making it. ConnectionTimeoutMs is RETAINED: it is projected into the shared library and is not authenticator-specific. Docs corrected rather than left to rot: - CLAUDE.md's "LDAP Authentication" section described the deleted file as the data-plane authenticator. Rewritten, including a warning not to "restore" it on seeing the LDAP wiring that AdminUI legitimately still needs. - infra/README §5b (the GLAuth outage live gate) marked RETIRED with the reason, rather than silently dropped — the harness GLAuth is still used by OTOPCUA_HARNESS_USE_LDAP=1 and is unaffected. - docs/security.md's five-key resilience table cut to the one surviving key, and a new API-key identity section added. Historical archreview/ records are left as-is; they are dated accounts. Host.IntegrationTests 211 tests (was 230, i.e. exactly the 19 removed) with the same 3 pre-existing failures verified against clean master. Host 37, Security 89. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- CLAUDE.md | 36 +- docs/security.md | 64 ++- infra/README.md | 19 +- .../Configuration/LdapOptionsValidator.cs | 15 +- .../OpcUa/LdapOpcUaUserAuthenticator.cs | 260 ------------ .../OpcUaApplicationHost.cs | 4 +- .../Ldap/LdapOptions.cs | 44 +- .../ServiceCollectionExtensions.cs | 4 +- .../LdapAuthResilienceTests.cs | 389 ------------------ .../LdapGlauthOutageLiveTests.cs | 190 --------- .../LdapOpcUaUserAuthenticatorTests.cs | 152 ------- .../LdapOptionsBindingTests.cs | 26 +- .../LdapOptionsValidatorTests.cs | 73 ---- 13 files changed, 124 insertions(+), 1152 deletions(-) delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapGlauthOutageLiveTests.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 39254018..bb9b51f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,7 +274,7 @@ central SQL Server. > **Infra index:** [`infra/README.md`](infra/README.md) is the canonical index of all testing + > deployment infrastructure — the Docker host layout, driver fixtures, the env-gated **live-gate** -> recipes (S7 blackhole, GLAuth outage, HistorianGateway LiveIntegration), the integration-test +> recipes (S7 blackhole, HistorianGateway LiveIntegration), the integration-test > harness, the docker-dev rig, and the deploy setup. Start there; the sections below + `docs/v2/dev-environment.md` carry the detail. > **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28). @@ -591,9 +591,39 @@ Merged to master (`26fad75c`); **live gate PASSED** (`1281aebf` — driver nodes Task 9 landed (`3a590a0c`). See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and `docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4. -## LDAP Authentication +## Authentication: API keys on the data plane, LDAP on AdminUI (#520) -The server uses LDAP-based user authentication via the `Security:Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server, and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapOpcUaUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`) implements `IOpcUaUserAuthenticator`, delegating the LDAP bind + group lookup to `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`, an `ILdapAuthService`). See `docs/security.md` for the full guide. +⚠️ **Changed 2026-07-31. The OPC UA data plane no longer uses LDAP at all.** It is machine-to-machine, +so a client presents an OPC UA **UserName** token with the fixed username **`apikey`** and an +`otua__` credential as the password. `ApiKeyOpcUaUserAuthenticator` +(`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/`) implements `IOpcUaUserAuthenticator`: one indexed lookup +on `keyId` plus one peppered HMAC-SHA256, `FixedTimeEquals`, **no network I/O**. The key resolves to a +**principal**; `NodeAcl.Principal` rows grant it permissions. `LdapOpcUaUserAuthenticator` is +**deleted**, along with four `Security:Ldap` keys that existed only for it (`AuthTimeoutMs`, +`MaxConcurrentAuthentications`, `OutageFailureThreshold`, `OutageCooldownSeconds`) — they bounded an +LDAP bind running inline in the SDK's synchronous, `SessionManager`-lock-holding impersonation +callback, and nothing on the remaining LDAP path has that property. + +**LDAP remains, for AdminUI login only** (`Security:Ldap`, `OtOpcUaLdapAuthService` → +`ILdapAuthService`, group→role mapping via `GroupToRole` + `LdapGroupRoleMapping`). Do not "restore" +the data-plane authenticator when you see LDAP wiring in `Program.cs` — it is there for the AdminUI. + +Two things to know before relying on any of this: + +- ⚠️ **Node ACLs are still not enforced per node.** A key's grants collapse into the two existing + server-wide role gates (`WriteOperate` on Value writes, `AlarmAck` on Part 9 methods), and + **only `Cluster`-scoped grants count** — `ApiKeyInterimRoleMapper` deliberately under-grants, because + honouring a narrower grant's flags while unable to honour its *scope* would turn "WriteOperate on + Equipment X" into server-wide write authority. Read, Browse, HistoryRead and CreateMonitoredItems + remain ungated for any admitted session, **including Anonymous**. +- ⚠️ **A driver-only node has no key source yet.** `ConfigDbApiKeyStore` needs a ConfigDb, which Phase 4 + removed from driver-only nodes; until the artifact-backed store lands they register + `NullOpcUaUserAuthenticator` and deny every UserName token. Note `OtOpcUaServerHostedService` takes + `IOpcUaUserAuthenticator` as a **required** ctor parameter — leaving it unregistered fails DI and the + node does not boot, so the deny-all must be registered explicitly. + +Remaining stages, and the rationale for every decision above, are in +`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide. Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.) diff --git a/docs/security.md b/docs/security.md index 5604c76d..a48a01cf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -162,19 +162,67 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s `GroupToRole` maps LDAP group names → roles (case-insensitive); a user gets every role whose source group is in their membership. The values are the canonical control-plane role strings (`Viewer` / `Designer` / `Administrator`, plus the appsettings-only `Operator` for the `DriverOperator` policy); the same map also supplies data-plane role strings (`ReadOnly`, `WriteOperate`, `WriteTune`, `WriteConfigure`, `AlarmAck`) — see [Role grant source (data-plane)](#role-grant-source-data-plane) below. `UserNameAttribute: "sAMAccountName"` is the critical AD override — the GLAuth dev default is `cn`, which is not how AD users are looked up; use `userPrincipalName` instead if operators log in with `user@corp.example.com` form. `LdapOptionsValidator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`) fails startup when `Transport = None` and `AllowInsecure = false` on a real-LDAP (non-DevStub) config. -### LDAP resilience — timeout, concurrency, outage circuit (arch-review 03/S2) +### API-key identity (OPC UA data plane) -The OPC UA SDK raises its impersonation (UserName) callback **synchronously**, inside a `SessionManager`-wide event lock (verified against `OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378). A slow or hung LDAP bind inside that callback therefore does not stall one SDK thread — it **serializes every session activation server-wide** (Anonymous and X509 included). Since the callback is irreducibly synchronous, `LdapOpcUaUserAuthenticator` bounds it at its own boundary with five `Security:Ldap` keys (all validated at startup by `LdapOptionsValidator` when `Enabled && !DevStubMode`): +The OPC UA data plane is **machine-to-machine**, so a consuming system authenticates with an API key +rather than an LDAP user (Gitea #520). The client presents an OPC UA **UserName** token whose username +is the fixed literal `apikey` and whose password is the key: + +``` +otua__ +``` + +`ApiKeyOpcUaUserAuthenticator` verifies it — one indexed lookup on `keyId` plus one peppered +HMAC-SHA256 over the secret, compared with `CryptographicOperations.FixedTimeEquals`. No network I/O. +The key resolves to a **principal**, and `NodeAcl` rows naming that principal are what grant it +permissions. + +| Key | Notes | +|---|---| +| `Security:ApiKeys:Username` | The static username clients present. Default `apikey`. Checked rather than ignored, so a typo is an explicit denial. | +| `Security:ApiKeys:Pepper` | Server-side pepper for the stored hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** Rotating it invalidates every existing key. Has no default: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a working-looking deployment with no protection. | + +Secrets are never stored — only the hash. The plaintext is shown **once** at creation and is +unrecoverable afterwards. + +**Every rejection returns the same opaque message** ("Invalid API key"), with the real reason logged +server-side only, so the server cannot be used as an oracle for whether a key id exists or which half +of a credential was wrong. + +⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no +ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing +`Enabled` takes effect on a node only once a new artifact reaches it. + +⚠️ **Node ACLs are still not enforced per node.** Until the enforcement stages land, a key's grants are +collapsed into the two existing server-wide role gates, and **only `Cluster`-scoped grants count** — +a narrower grant confers nothing rather than silently widening to server-wide authority. Reads, +browses, subscriptions and history remain ungated for any admitted session. See +`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. + +### LDAP resilience — mostly retired with the data-plane authenticator (#520) + +> **Changed 2026-07-31.** The OPC UA **data plane** no longer uses LDAP at all — it authenticates with +> API keys (see [API-key identity](#api-key-identity-opc-ua-data-plane)). `LdapOpcUaUserAuthenticator` +> is deleted, and with it four of the five `Security:Ldap` resilience keys: **`AuthTimeoutMs`**, +> **`MaxConcurrentAuthentications`**, **`OutageFailureThreshold`** and **`OutageCooldownSeconds`**. +> Setting them now does nothing (options binding ignores unknown keys); remove them from any config +> that carries them. + +Those four existed for one specific reason. The OPC UA SDK raises its impersonation callback +**synchronously**, inside a `SessionManager`-wide event lock (verified against +`OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378), so a slow or hung LDAP bind in that callback did +not stall one SDK thread — it **serialized every session activation server-wide**, Anonymous and X509 +included. That constraint is gone: key verification is one indexed lookup plus one HMAC, with no +network I/O on the path. + +LDAP now runs only on **AdminUI login**, an ordinary async request path where a slow directory affects +the one request making it. One key remains: | Key | Default | Bounds | |---|---|---| -| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. First line of defence against an outage. | -| `AuthTimeoutMs` | `15000` | Hard whole-flow wall-clock bound (bind **and** role-map) enforced at the authenticator boundary; a slow/hung flow denies **"Authentication timed out"** (fail-closed) and releases the SDK thread. Deliberately `> ConnectionTimeoutMs` so a healthy-but-slow flow is not cut off. | -| `MaxConcurrentAuthentications` | `8` | Max in-flight authentications; excess denies **"Authentication backend busy"** (local backpressure — does **not** feed the circuit). Bounds accumulation of orphaned/timed-out binds during an outage. | -| `OutageFailureThreshold` | `3` | Consecutive **system-side** failures (boundary timeout, directory-unreachable, or unexpected throw) that open the outage circuit. Any success or user-credential deny resets the streak. `0` disables the circuit. | -| `OutageCooldownSeconds` | `15` | How long the open circuit fast-denies **"Authentication backend unavailable"** without touching LDAP; after cooldown the next call half-open-probes, and one more system failure re-opens immediately. | +| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. Validated `> 0` at startup by `LdapOptionsValidator`. | + -Defaults are behaviour-neutral on a healthy directory. All new paths are **fail-closed** (they deny). A user-credential deny (wrong password) is fast when the directory is up and never trips the circuit; only a directory *outage* opens it. --- diff --git a/infra/README.md b/infra/README.md index a22f6233..e8cc302a 100644 --- a/infra/README.md +++ b/infra/README.md @@ -135,17 +135,16 @@ export S7_TIMEOUT_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otop dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~ConnectTimeoutOutage" ``` -### 5b. LDAP GLAuth outage/recovery (`LdapGlauthOutageLiveTests`) -Drives the directory-outage circuit through the production auth path against the harness GLAuth -(§4), paused/unpaused. Also verifies the GLAuth swap binds correctly. +### 5b. LDAP GLAuth outage/recovery — **RETIRED 2026-07-31 (#520)** -```bash -ssh dohertj2@10.100.0.35 'cd ~/otopcua-harness && docker compose up -d ldap' -export GLAUTH_LIVE_HOST=10.100.0.35 # port defaults to 3894 (GLAUTH_LIVE_PORT) -export GLAUTH_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-harness-ldap-1"' -export GLAUTH_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-harness-ldap-1"' -dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapGlauthOutageLive" -``` +`LdapGlauthOutageLiveTests` and the directory-outage circuit it drove were deleted with +`LdapOpcUaUserAuthenticator`. The OPC UA data plane authenticates with **API keys**, not LDAP, so no +LDAP bind runs inside the SDK's synchronous impersonation callback any more — which is the only +reason that circuit, its wall-clock bound, and its concurrency cap existed. LDAP remains only on the +AdminUI login path, where a slow directory stalls one request rather than serializing every OPC UA +session activation. + +The harness GLAuth (§4) is still used by `OTOPCUA_HARNESS_USE_LDAP=1` and is unaffected. ### 5c. HistorianGateway LiveIntegration soak (`Category=LiveIntegration`) — needs VPN Exercises the gateway read/write/alarm/EnsureTags paths against a **real AVEVA historian**. Requires diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs index 18309fe2..de39a261 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs @@ -48,17 +48,12 @@ public sealed class LdapOptionsValidator : OptionsValidatorBase !(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."); - // S2 resilience knobs — a non-positive timeout / concurrency bound would silently disable the - // wall-clock/concurrency protection the OPC UA data-plane authenticator relies on, so fail fast. + // A non-positive connect/search timeout would leave the shared library unbounded against a hung + // directory, so fail fast. The other four S2 knobs validated here (AuthTimeoutMs, + // MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds) went with + // LdapOpcUaUserAuthenticator in #520 — they guarded an LDAP bind running inline on OPC UA + // session activation, and no remaining LDAP consumer runs on that path. builder.RequireThat(options.ConnectionTimeoutMs > 0, "Ldap:ConnectionTimeoutMs must be > 0."); - builder.RequireThat(options.AuthTimeoutMs > 0, - "Ldap:AuthTimeoutMs must be > 0."); - builder.RequireThat(options.MaxConcurrentAuthentications > 0, - "Ldap:MaxConcurrentAuthentications must be > 0."); - // An open circuit with no cooldown would re-probe LDAP on every call — pointless. Only enforce - // a positive cooldown when the circuit is actually enabled (OutageFailureThreshold > 0; 0 disables). - builder.RequireThat(options.OutageFailureThreshold <= 0 || options.OutageCooldownSeconds > 0, - "Ldap:OutageCooldownSeconds must be > 0 when Ldap:OutageFailureThreshold > 0 (0 disables the circuit)."); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs deleted file mode 100644 index bd97cd93..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs +++ /dev/null @@ -1,260 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ZB.MOM.WW.Auth.Abstractions.Roles; -using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; -using ZB.MOM.WW.OtOpcUa.Security.Ldap; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -/// -/// Production adapter that bridges OPC UA UserName -/// tokens to the same 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 are resolved through the shared -/// 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. -/// -/// -/// -/// This authenticator is registered as a singleton, but -/// (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. -/// -/// -/// Resilience (arch-review 03/S2). The OPC UA SDK raises the impersonation callback that -/// reaches here synchronously, inside a SessionManager-wide event lock (verified -/// against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378 — -/// SessionManager.ActivateSessionAsync holds m_eventLock while invoking the -/// void impersonation delegate). A slow / hung LDAP bind inside that callback therefore does -/// not merely stall one SDK thread — it serializes every session activation server-wide -/// (Anonymous and X509 included). The callback contract is irreducibly synchronous (there is no -/// async impersonation callback), so the fix lives entirely at this boundary: a hard whole-flow -/// wall-clock bound () and a bounded in-flight concurrency cap -/// (), both fail-closed. All new paths deny. -/// -/// -public sealed class LdapOpcUaUserAuthenticator : IOpcUaUserAuthenticator -{ - private readonly ILdapAuthService _ldap; - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - private readonly LdapOptions _options; - - /// Bounds in-flight authentication flows. A boundary timeout abandons its core task, which - /// keeps holding its slot until it actually completes — so an outage cannot accumulate unbounded - /// orphan binds. - private readonly SemaphoreSlim _gate; - - /// Guards the outage-circuit state ( + - /// ). - private readonly object _circuitLock = new(); - - /// Count of CONSECUTIVE system-side failures; reset by any success / user-side deny. - private int _consecutiveSystemFailures; - - /// the circuit stays open until; 0 = closed. - private long _circuitOpenUntilTicks; - - /// Constructs the authenticator from its dependencies and the bound LDAP options. - /// The app LDAP auth service (shared with the Admin UI login flow). - /// Opens a per-call DI scope to resolve the scoped role mapper. - /// The bound (timeout + concurrency knobs). - /// The logger. - public LdapOpcUaUserAuthenticator( - ILdapAuthService ldap, - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger logger) - { - _ldap = ldap; - _scopeFactory = scopeFactory; - _options = options.Value; - _logger = logger; - _gate = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentAuthentications)); - } - - /// - public async Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) - { - var authTimeout = TimeSpan.FromMilliseconds(Math.Max(1, _options.AuthTimeoutMs)); - - // Directory-outage circuit: after a run of consecutive system-side failures, fail fast WITHOUT - // touching LDAP for the cooldown window — so a sustained outage cannot serialize every session - // activation behind a per-call stall (the SDK raises this callback under a server-wide lock). - if (IsCircuitOpen()) - { - _logger.LogDebug( - "LDAP outage circuit open — fast-denying OPC UA user {User} without a directory call", username); - return OpcUaUserAuthResult.Deny("Authentication backend unavailable"); - } - - // Bounded in-flight concurrency: acquire a slot within our own wall-clock budget. All slots - // busy is LOCAL backpressure, not directory evidence — deny "busy" (fail-closed) and do NOT - // feed the outage circuit. - if (!await _gate.WaitAsync(authTimeout, ct).ConfigureAwait(false)) - { - _logger.LogWarning( - "LDAP authentication backend busy (concurrency cap {Cap}) — denying OPC UA user {User}", - _options.MaxConcurrentAuthentications, username); - return OpcUaUserAuthResult.Deny("Authentication backend busy"); - } - - // Release the slot when the CORE task completes — deliberately NOT in a finally here. A boundary - // timeout below abandons the core task (it runs to completion in the background; the underlying - // library is documented never-throw, so there are no unobserved exceptions), and it must keep - // counting against the cap until it finishes. The point of the boundary is to release the SDK - // thread (and m_eventLock), not to cancel the bind. - var core = AuthenticateCoreAsync(username, password, ct); - _ = core.ContinueWith( - static (_, state) => ((SemaphoreSlim)state!).Release(), - _gate, - CancellationToken.None, - TaskContinuationOptions.None, - TaskScheduler.Default); - - try - { - var outcome = await core.WaitAsync(authTimeout, ct).ConfigureAwait(false); - RecordOutcome(outcome.SystemFailure, username); - return outcome.Result; - } - catch (TimeoutException) - { - // A boundary timeout is directory evidence — it feeds the circuit. - RecordOutcome(systemFailure: true, username); - _logger.LogWarning( - "LDAP authentication timed out after {Timeout} for OPC UA user {User} — denying (fail-closed)", - authTimeout, username); - return OpcUaUserAuthResult.Deny("Authentication timed out"); - } - } - - /// Whether the outage circuit is currently open (fast-deny window active). Always false when - /// the circuit is disabled ( ≤ 0). - private bool IsCircuitOpen() - { - if (_options.OutageFailureThreshold <= 0) return false; - lock (_circuitLock) - { - return _circuitOpenUntilTicks > Environment.TickCount64; - } - } - - /// - /// Updates the outage-circuit state from one authentication outcome. A system-side failure advances - /// the consecutive-failure streak and opens the circuit once it reaches - /// ; any non-system outcome (success or user-side - /// deny) resets the streak and closes the circuit. Half-open behaviour is implicit: once the cooldown - /// elapses lets the next call through with the streak still at threshold, - /// so a single further system failure re-opens immediately. - /// - /// Whether this outcome was a system-side (directory) failure. - /// The login name, for diagnostics. - private void RecordOutcome(bool systemFailure, string username) - { - if (_options.OutageFailureThreshold <= 0) return; // circuit disabled - - lock (_circuitLock) - { - if (systemFailure) - { - _consecutiveSystemFailures++; - if (_consecutiveSystemFailures >= _options.OutageFailureThreshold) - { - var wasOpen = _circuitOpenUntilTicks > Environment.TickCount64; - _circuitOpenUntilTicks = - Environment.TickCount64 + (long)Math.Max(1, _options.OutageCooldownSeconds) * 1000L; - if (!wasOpen) - { - _logger.LogWarning( - "LDAP outage circuit OPENED after {Failures} consecutive system-side failures — " + - "fast-denying for {Cooldown}s (last user {User})", - _consecutiveSystemFailures, _options.OutageCooldownSeconds, username); - } - } - } - else - { - if (_consecutiveSystemFailures >= _options.OutageFailureThreshold) - { - _logger.LogInformation( - "LDAP outage circuit CLOSED — directory recovered (probe succeeded for {User})", username); - } - _consecutiveSystemFailures = 0; - _circuitOpenUntilTicks = 0; - } - } - } - - /// - /// The unbounded core authenticate flow (bind + role-map) that the outer - /// wraps in a wall-clock bound + concurrency cap. Fail-closed: - /// any backend throw becomes a denial rather than escaping into the SDK. The returned - /// flags directory-side faults (a system-side - /// deny, or an unexpected throw) so the caller can feed - /// the outage circuit; a user-credential deny is NOT a system failure. - /// - /// The username to authenticate. - /// The cleartext password. - /// Cancellation token. - private async Task AuthenticateCoreAsync(string username, string password, CancellationToken ct) - { - try - { - var result = await _ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false); - if (!result.Success) - { - return new CoreOutcome( - OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials"), result.IsSystemFailure); - } - - var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false); - return new CoreOutcome(OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles), SystemFailure: false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username); - return new CoreOutcome(OpcUaUserAuthResult.Deny("Authentication backend error"), SystemFailure: true); - } - } - - /// The result of the core authenticate flow plus whether it was a system-side (directory) - /// failure — the signal that drives the outage circuit. - /// The auth result to return to the SDK. - /// Whether the outcome was a directory-side fault. - private readonly record struct CoreOutcome(OpcUaUserAuthResult Result, bool SystemFailure); - - /// - /// Resolves the user's roles from their LDAP groups via the scoped - /// , 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. - /// - /// The LDAP groups returned by the directory. - /// Pre-resolved roles (empty on the real path; FleetAdmin under DevStub). - /// The login name, for diagnostics. - /// Cancellation token. - private async Task> ResolveRolesAsync( - IReadOnlyList groups, IReadOnlyList preResolved, string username, CancellationToken ct) - { - try - { - await using var scope = _scopeFactory.CreateAsyncScope(); - var mapper = scope.ServiceProvider.GetRequiredService>(); - var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false); - - var roles = new HashSet(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; - } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 7a62694e..037feed2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -246,7 +246,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// (GetAwaiter().GetResult() under ) is irreducible: the /// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded /// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live - /// inside the authenticator (LdapOpcUaUserAuthenticator, arch-review 03/S2) — NOT here. + /// inside the authenticator — NOT here. (#520 replaced the LDAP authenticator with + /// ApiKeyOpcUaUserAuthenticator, whose verification does no network I/O at all, so the + /// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.) /// /// The user authenticator to validate credentials. /// The impersonation event arguments to process. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs index ffb5db57..3eb973bd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs @@ -88,44 +88,18 @@ public sealed class LdapOptions /// per-operation search inside a single bind flow. Projected into the library's /// via . The /// default (10000) matches the library default, so behaviour is unchanged unless configured. - /// This is the FIRST line of defence against a directory outage; the whole-flow - /// is the backstop (03/S2). ///
+ /// + /// This is now the ONLY directory-outage bound. Its former backstops — AuthTimeoutMs, + /// MaxConcurrentAuthentications, OutageFailureThreshold and + /// OutageCooldownSeconds — were removed with LdapOpcUaUserAuthenticator in #520 + /// (see the note on this type). They existed because an LDAP bind ran inline on OPC UA session + /// activation, under a SessionManager-wide lock; nothing on the remaining AdminUI login path has + /// that property, so they had no consumer left. Setting any of them in configuration is now + /// silently ignored rather than an error, which is how options binding treats unknown keys. + /// public int ConnectionTimeoutMs { get; set; } = 10_000; - /// - /// Hard whole-flow wall-clock bound (in milliseconds) the OPC UA data-plane authenticator - /// (LdapOpcUaUserAuthenticator) applies across the ENTIRE authenticate flow — bind plus - /// role-map — so the OPC UA SDK's synchronous impersonation callback (raised under a - /// SessionManager-wide event lock) can never be wedged by a slow / hung directory. - /// Deliberately > (default 15000) so a healthy-but-slow - /// full flow is not cut off while an outage is bounded by the library timeout first (03/S2). - /// - public int AuthTimeoutMs { get; set; } = 15_000; - - /// - /// Maximum number of in-flight OPC UA authentication flows the data-plane authenticator admits - /// at once. A caller that cannot acquire a slot within its budget is denied "backend busy" - /// (local backpressure, not counted against the outage circuit). Bounds accumulation of - /// orphaned / timed-out bind tasks during an outage (03/S2). Default 8. - /// - public int MaxConcurrentAuthentications { get; set; } = 8; - - /// - /// Number of CONSECUTIVE system-side failures (boundary timeout, directory-unreachable result, - /// or unexpected throw) after which the data-plane authenticator's outage circuit opens and - /// denies instantly for without touching LDAP. Any success - /// or user-side (credential) deny resets the counter. 0 disables the circuit (03/S2). - /// Default 3. - /// - public int OutageFailureThreshold { get; set; } = 3; - - /// - /// How long (in seconds) the outage circuit stays open before a half-open probe is allowed - /// through. Only meaningful when > 0. Default 15 (03/S2). - /// - public int OutageCooldownSeconds { get; set; } = 15; - /// /// Projects the wire fields onto the shared ZB.MOM.WW.Auth.Ldap /// the directory client consumes. App-only concerns diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 1a15e0d5..6561d662 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -57,7 +57,7 @@ public static class ServiceCollectionExtensions // 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 + // AdminUI login on admin-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. @@ -65,7 +65,7 @@ public static class ServiceCollectionExtensions // Shared ZB.MOM.WW.Auth group→role mapper seam. Wraps the existing RoleMapper.Map + // RoleMapper.Merge logic; consumed by AuthEndpoints.cs (/auth/login) and by - // LdapOpcUaUserAuthenticator (OPC UA UserName session auth). Scoped to match + // AdminUI login (the OPC UA data plane uses API keys since #520). Scoped to match // ILdapGroupRoleMappingService (DbContext-backed, registered Scoped) — a singleton here // would capture the scoped DB service. services.TryAddScoped, OtOpcUaGroupRoleMapper>(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs deleted file mode 100644 index f0d27517..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs +++ /dev/null @@ -1,389 +0,0 @@ -using System.Diagnostics; -using System.Text; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Opc.Ua; -using Opc.Ua.Server; -using Shouldly; -using Xunit; -using ZB.MOM.WW.Auth.Abstractions.Roles; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; -using ZB.MOM.WW.OtOpcUa.Security.Ldap; -using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; - -namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; - -/// -/// Resilience guard for (arch-review 03/S2). The OPC UA SDK -/// raises the impersonation callback synchronously under a SessionManager-wide event lock, so a -/// slow / hung LDAP bind inside it does not merely stall one SDK thread — it serializes EVERY session -/// activation server-wide. These tests prove the authenticator enforces a hard wall-clock bound, -/// bounded in-flight concurrency, and a directory-outage circuit at its own boundary (the SDK callback -/// being irreducibly synchronous), all fail-closed. No real LDAP server is used — a delaying fake -/// reproduces the outage shape offline. -/// -public sealed class LdapAuthResilienceTests -{ - /// - /// RED repro (03/S2): a backend that takes 5 s must NOT hold the caller for 5 s — the authenticator's - /// wall-clock bound denies (fail-closed) well before then. On current code (no bound) this fails: the - /// call returns Allow after the full 5 s. - /// - [Fact] - public async Task Authenticate_slow_backend_denies_within_AuthTimeout() - { - var ldap = new DelayingFakeLdap( - TimeSpan.FromSeconds(5), - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, new LdapOptions { AuthTimeoutMs = 200 }); - - var sw = Stopwatch.StartNew(); - var result = await sut - .AuthenticateUserNameAsync("alice", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - sw.Stop(); - - result.Success.ShouldBeFalse(); - result.Error.ShouldNotBeNull(); - result.Error!.ShouldContain("timed out"); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5)); - } - - /// A fast, healthy backend is unaffected by the resilience wrapping — success + roles pass - /// through untouched (03/S2). - [Fact] - public async Task Authenticate_fast_success_unaffected() - { - var ldap = new DelayingFakeLdap( - TimeSpan.Zero, - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray()); - var sut = Build(ldap, mapper, new LdapOptions()); - - var result = await sut - .AuthenticateUserNameAsync("alice", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.DisplayName.ShouldBe("Alice"); - result.Roles.ShouldBe(new[] { "Designer" }); - } - - /// With the concurrency cap saturated (a parked in-flight bind holding the only slot), a - /// second call is denied "backend busy" within its own budget — it never waits behind the parked - /// bind indefinitely (03/S2). - [Fact] - public async Task Authenticate_saturated_concurrency_denies_busy() - { - var ldap = new GatedFakeLdap( - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - // MaxConcurrentAuthentications = 1 → the second call cannot get a slot; small AuthTimeoutMs so the - // "busy" denial is quick and the first call's boundary also trips fast (its core stays parked, - // holding the slot). - var sut = Build(ldap, mapper, new LdapOptions { MaxConcurrentAuthentications = 1, AuthTimeoutMs = 300 }); - - // First call: acquires the only slot; its core parks in the gated fake. - var first = sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None); - - // Wait until the fake has actually been entered (slot acquired, core parked). - var spin = Stopwatch.StartNew(); - while (ldap.Entered == 0 && spin.Elapsed < TimeSpan.FromSeconds(5)) - await Task.Delay(10, TestContext.Current.CancellationToken); - ldap.Entered.ShouldBe(1); - - // Second concurrent call: the slot is held by the parked core, so acquisition times out → busy. - var second = await sut - .AuthenticateUserNameAsync("bob", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - - second.Success.ShouldBeFalse(); - second.Error.ShouldNotBeNull(); - second.Error!.ShouldContain("busy"); - ldap.Entered.ShouldBe(1, "the second call must be denied without ever reaching the backend"); - - // Release the parked bind so the first call's core completes cleanly (no orphaned resources). - ldap.Release(); - await first.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - } - - /// After OutageFailureThreshold consecutive system-side failures the circuit opens and - /// the next call is fast-denied "backend unavailable" WITHOUT touching LDAP (03/S2). - [Fact] - public async Task Consecutive_system_failures_open_circuit_and_fast_deny() - { - var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure()); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 }); - - for (var i = 0; i < 3; i++) - { - var r = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - r.Success.ShouldBeFalse(); - } - - ldap.Calls.ShouldBe(3); - - var sw = Stopwatch.StartNew(); - var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - sw.Stop(); - - blocked.Success.ShouldBeFalse(); - blocked.Error.ShouldNotBeNull(); - blocked.Error!.ShouldContain("unavailable"); - ldap.Calls.ShouldBe(3, "an open circuit must fast-deny without reaching the backend"); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1)); - } - - /// A user-side (credential) deny between system failures resets the streak, so the circuit - /// never opens — user denies are not directory evidence (03/S2). - [Fact] - public async Task User_side_deny_resets_the_system_failure_streak() - { - var ldap = new ScriptedFakeLdap( - SystemFailure(), SystemFailure(), UserDeny(), SystemFailure(), SystemFailure()); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 }); - - // 5 scripted calls: sys, sys, user(reset), sys, sys → streak ends at 2 (< 3), circuit stays closed. - for (var i = 0; i < 5; i++) - { - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - } - - // A 6th call must still REACH the backend (circuit never opened). - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - ldap.Calls.ShouldBe(6); - } - - /// After the cooldown elapses the circuit half-opens: the next call probes the backend and a - /// success closes the circuit (03/S2). - [Fact] - public async Task Half_open_probe_after_cooldown_closes_on_success() - { - var ldap = new ScriptedFakeLdap( - SystemFailure(), SystemFailure(), SystemFailure(), - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 1, AuthTimeoutMs = 5000 }); - - for (var i = 0; i < 3; i++) - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - ldap.Calls.ShouldBe(3); - - // Circuit open: this call is fast-denied without touching the backend. - var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - blocked.Error!.ShouldContain("unavailable"); - ldap.Calls.ShouldBe(3); - - // Wait past the cooldown, then a probe reaches the backend and succeeds → circuit closes. - await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken); - - var probe = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - probe.Success.ShouldBeTrue(); - ldap.Calls.ShouldBe(4, "the half-open probe reaches the backend"); - } - - /// OutageFailureThreshold = 0 disables the circuit — it never opens, every call reaches - /// the backend (03/S2). - [Fact] - public async Task Circuit_disabled_never_opens() - { - var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure()); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 0, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 }); - - for (var i = 0; i < 6; i++) - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - - ldap.Calls.ShouldBe(6, "a disabled circuit never fast-denies"); - } - - /// - /// Outage-sim (offline-safe): the REAL + real shared library - /// pointed at an UNROUTABLE host (a TCP blackhole — the same outage shape as "stop the GLAuth - /// container"), driven through the REAL internal - /// block-bridge. The activation must fail bounded (well under the SDK's default ~10-20 s stall) with - /// and no granted identity (03/S2). - /// - [Fact] - public async Task Activation_with_unreachable_directory_fails_within_bound() - { - var options = new LdapOptions - { - Enabled = true, - DevStubMode = false, - Server = "10.255.255.1", // RFC-blackhole: SYN goes nowhere - Port = 3893, - Transport = LdapTransport.None, - AllowInsecure = true, - SearchBase = "dc=zb,dc=local", - ConnectionTimeoutMs = 500, - AuthTimeoutMs = 1000, - }; - - var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger.Instance); - var mapper = new FakeMapper(g => g.ToArray()); - var authenticator = new LdapOpcUaUserAuthenticator( - ldap, ScopeFactoryWith(mapper), Options.Create(options), - NullLogger.Instance); - - var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; - var policy = new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256" }; - var args = new ImpersonateEventArgs(token, policy, new EndpointDescription()); - - var sw = Stopwatch.StartNew(); - await Task.Run(() => OpcUaApplicationHost.HandleImpersonation( - authenticator, args, NullLogger.Instance), TestContext.Current.CancellationToken) - .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); - sw.Stop(); - - args.Identity.ShouldBeNull(); - args.IdentityValidationError.ShouldNotBeNull(); - args.IdentityValidationError.Code.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenRejected); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3)); - } - - private static LdapAuthResult SystemFailure() => - new(false, null, "u", Array.Empty(), Array.Empty(), "Unexpected authentication error") - { IsSystemFailure = true }; - - private static LdapAuthResult UserDeny() => - new(false, null, "u", Array.Empty(), Array.Empty(), "Invalid username or password") - { IsSystemFailure = false }; - - private static LdapOpcUaUserAuthenticator Build( - ILdapAuthService ldap, IGroupRoleMapper mapper, LdapOptions options) => - new(ldap, ScopeFactoryWith(mapper), Options.Create(options), - NullLogger.Instance); - - /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper. - private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) - { - var services = new ServiceCollection(); - services.AddScoped(_ => mapper); - return services.BuildServiceProvider().GetRequiredService(); - } - - /// - /// A fake that awaits a configurable delay (observing the token) before - /// returning a fixed result — the offline stand-in for a slow / hung directory. Counts invocations so - /// tests can assert the outage circuit denies WITHOUT reaching the backend. - /// - private sealed class DelayingFakeLdap : ILdapAuthService - { - private readonly TimeSpan _delay; - private readonly LdapAuthResult _then; - private int _calls; - - /// Initializes the fake with a per-call delay and the result to return afterwards. - /// How long each authenticate call blocks before returning. - /// The result returned once the delay elapses. - public DelayingFakeLdap(TimeSpan delay, LdapAuthResult then) - { - _delay = delay; - _then = then; - } - - /// The number of times the backend was actually invoked. - public int Calls => Volatile.Read(ref _calls); - - /// Awaits the configured delay, then returns the canned result. - /// The username (ignored). - /// The password (ignored). - /// Cancellation token, observed by the delay. - public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - { - Interlocked.Increment(ref _calls); - await Task.Delay(_delay, ct).ConfigureAwait(false); - return _then; - } - } - - /// - /// A fake that parks ASYNCHRONOUSLY (awaits a - /// ) until the test calls — used to hold an - /// in-flight bind so the concurrency cap is deterministically saturated WITHOUT blocking a thread. - /// Records how many calls have entered (past the slot acquire). - /// - private sealed class GatedFakeLdap : ILdapAuthService - { - private readonly TaskCompletionSource _park = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly LdapAuthResult _then; - private int _entered; - - /// Initializes the fake with the result to return once released. - /// The result returned once is called. - public GatedFakeLdap(LdapAuthResult then) => _then = then; - - /// Number of authenticate calls that reached the backend body. - public int Entered => Volatile.Read(ref _entered); - - /// Releases the parked bind so its awaiting call can complete. - public void Release() => _park.TrySetResult(); - - /// Marks entry, awaits the parking gate, then returns the canned result. - /// The username (ignored). - /// The password (ignored). - /// Cancellation token, observed while parked. - public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - { - Interlocked.Increment(ref _entered); - await _park.Task.WaitAsync(ct).ConfigureAwait(false); - return _then; - } - } - - /// - /// A fake that returns a scripted sequence of results (one per call; - /// the last entry repeats once exhausted). Counts calls so the outage-circuit tests can assert the - /// backend was — or was NOT — reached on a given call. - /// - private sealed class ScriptedFakeLdap : ILdapAuthService - { - private readonly IReadOnlyList _script; - private int _calls; - - /// Initializes the fake with the scripted results, returned in order. - /// The results to return, one per call; the last repeats once exhausted. - public ScriptedFakeLdap(params LdapAuthResult[] script) => _script = script; - - /// The number of times the backend was invoked. - public int Calls => Volatile.Read(ref _calls); - - /// Returns the next scripted result (clamped to the last entry). - /// The username (ignored). - /// The password (ignored). - /// The cancellation token (ignored). - public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - { - var idx = Interlocked.Increment(ref _calls) - 1; - return Task.FromResult(_script[Math.Min(idx, _script.Count - 1)]); - } - } - - /// Test group→role mapper driven by a delegate over the supplied groups. - private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper - { - /// Maps groups to roles via the configured delegate; Scope is always null. - /// The LDAP groups to map. - /// The cancellation token. - public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) - => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null)); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapGlauthOutageLiveTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapGlauthOutageLiveTests.cs deleted file mode 100644 index eeb52391..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapGlauthOutageLiveTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -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; -using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; - -namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; - -/// -/// R2-08 03/S2 LIVE gate — the directory-outage circuit exercised against a REAL GLAuth server -/// (the image + config PR #451 swapped in for the retired bitnami/openldap), driven through the -/// production auth path ( + ). -/// The offline prove the circuit logic with fakes + an -/// unroutable-host blackhole; this proves the same behaviour against an actual directory that is -/// paused mid-run (a real outage) and unpaused (recovery), and doubles as the first end-to-end -/// verification that the GLAuth swap binds correctly. -/// -/// -/// Triple-gated so it never runs by accident and always restores the shared GLAuth: -/// -/// GLAUTH_LIVE_HOST — the GLAuth host (e.g. 10.100.0.35); absent ⇒ skip; -/// GLAUTH_OUTAGE_START_CMD — pauses the GLAuth container (a real outage); and -/// GLAUTH_OUTAGE_STOP_CMD — unpauses it. -/// -/// A try/finally ALWAYS runs the stop command once the pause succeeded. Operator recipe: -/// -/// ssh dohertj2@10.100.0.35 "cd ~/otopcua-harness && docker compose up -d ldap" -/// export GLAUTH_LIVE_HOST=10.100.0.35 -/// export GLAUTH_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-harness-ldap-1"' -/// export GLAUTH_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-harness-ldap-1"' -/// dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapGlauthOutageLive" -/// -/// -[Trait("Category", "Integration")] -[Trait("Category", "LiveLdap")] -public sealed class LdapGlauthOutageLiveTests -{ - private const string HostEnvVar = "GLAUTH_LIVE_HOST"; - private const string PortEnvVar = "GLAUTH_LIVE_PORT"; - private const string StartCmdEnvVar = "GLAUTH_OUTAGE_START_CMD"; - private const string StopCmdEnvVar = "GLAUTH_OUTAGE_STOP_CMD"; - - private const int OutageThreshold = 3; - private const int CooldownSeconds = 2; - private static readonly TimeSpan AuthTimeout = TimeSpan.FromMilliseconds(2_500); - - /// - /// Healthy real binds succeed; pausing GLAuth makes consecutive binds fail bounded (never a - /// multi-second SDK stall) and opens the circuit into a sub-second fast-deny; unpausing + - /// cooldown lets a half-open probe close the circuit and binds succeed again. - /// - [Fact] - public async Task Real_glauth_outage_opens_circuit_then_recovers_on_unpause() - { - var host = Environment.GetEnvironmentVariable(HostEnvVar); - if (string.IsNullOrWhiteSpace(host)) - Assert.Skip($"Set {HostEnvVar} (+ {StartCmdEnvVar}/{StopCmdEnvVar}) to run the live GLAuth outage cycle."); - var port = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p) ? p : 3894; - var startCmd = Environment.GetEnvironmentVariable(StartCmdEnvVar); - var stopCmd = Environment.GetEnvironmentVariable(StopCmdEnvVar); - if (string.IsNullOrWhiteSpace(startCmd) || string.IsNullOrWhiteSpace(stopCmd)) - Assert.Skip($"Set both {StartCmdEnvVar} and {StopCmdEnvVar} (docker pause/unpause) to run this destructive live test."); - - var ct = TestContext.Current.CancellationToken; - var options = new LdapOptions - { - Enabled = true, - DevStubMode = false, - Server = host!, - Port = port, - Transport = LdapTransport.None, - AllowInsecure = true, - SearchBase = "dc=zb,dc=local", - ServiceAccountDn = "cn=serviceaccount,dc=zb,dc=local", - ServiceAccountPassword = "serviceaccount123", - ConnectionTimeoutMs = 2_000, - AuthTimeoutMs = (int)AuthTimeout.TotalMilliseconds, - OutageFailureThreshold = OutageThreshold, - OutageCooldownSeconds = CooldownSeconds, - }; - var authenticator = BuildAuthenticator(options); - - // ── 1. Healthy directory: real binds succeed, wrong creds deny (user-side, not an outage). ── - var alice = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - alice.Success.ShouldBeTrue("alice should bind against the live GLAuth"); - alice.Roles.ShouldNotBeEmpty("a healthy bind returns alice's mapped group roles"); - - var bob = await authenticator.AuthenticateUserNameAsync("bob", "bob123", ct); - bob.Success.ShouldBeTrue("bob should bind against the live GLAuth"); - - var wrong = await authenticator.AuthenticateUserNameAsync("alice", "wrong-password", ct); - wrong.Success.ShouldBeFalse("a wrong password is a user-side deny"); - - // ── 2. Outage: pause GLAuth. Consecutive binds fail BOUNDED, then the circuit fast-denies. ── - var paused = false; - try - { - await RunShellCommandAsync(startCmd!, ct); - paused = true; - - for (var i = 0; i < OutageThreshold; i++) - { - var sw = Stopwatch.StartNew(); - var outage = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - sw.Stop(); - - outage.Success.ShouldBeFalse($"bind #{i + 1} during the outage must fail closed"); - // The whole flow is wall-clock bounded — never the SDK's default ~10-20 s stall. - sw.Elapsed.ShouldBeLessThan(AuthTimeout + TimeSpan.FromSeconds(2), - "the authenticator must bound a hung directory call, not stall on it"); - } - - // Circuit is now open — the next call fast-denies WITHOUT a directory round-trip (sub-second). - var blockedSw = Stopwatch.StartNew(); - var blocked = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - blockedSw.Stop(); - - blocked.Success.ShouldBeFalse(); - blocked.Error.ShouldNotBeNull(); - blocked.Error!.ShouldContain("unavailable", Case.Insensitive, - "an open outage circuit fast-denies 'backend unavailable'"); - blockedSw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1), - "an open circuit fast-denies without a directory call"); - } - finally - { - if (paused) - await RunShellCommandAsync(stopCmd!, CancellationToken.None); // ALWAYS restore GLAuth - } - - // ── 3. Recovery: after the cooldown the half-open probe reaches the (restored) directory. ── - await Task.Delay(TimeSpan.FromSeconds(CooldownSeconds) + TimeSpan.FromSeconds(1), ct); - - var recovered = false; - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); - while (DateTime.UtcNow < deadline) - { - var probe = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - if (probe.Success) { recovered = true; break; } - await Task.Delay(TimeSpan.FromMilliseconds(500), ct); - } - recovered.ShouldBeTrue("the circuit must half-open and close once GLAuth is unpaused"); - } - - private static LdapOpcUaUserAuthenticator BuildAuthenticator(LdapOptions options) - { - var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger.Instance); - return new LdapOpcUaUserAuthenticator( - ldap, ScopeFactoryWith(new PassthroughMapper()), Options.Create(options), - NullLogger.Instance); - } - - /// Builds an whose scopes resolve the supplied mapper. - private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) - { - var services = new ServiceCollection(); - services.AddScoped(_ => mapper); - return services.BuildServiceProvider().GetRequiredService(); - } - - /// Runs an SSH/docker one-liner through the login shell (verbatim, as in the S7 outage gate). - private static async Task RunShellCommandAsync(string command, CancellationToken ct) - { - using var proc = new Process - { - StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }, - }; - proc.Start(); - await proc.WaitForExitAsync(ct); - proc.ExitCode.ShouldBe(0, $"outage command failed: {await proc.StandardError.ReadToEndAsync(ct)}"); - await Task.Delay(TimeSpan.FromSeconds(1), ct); // let the pause/unpause take effect - } - - /// Passes LDAP groups straight through as roles — the live gate asserts on the bind, not the mapping. - private sealed class PassthroughMapper : IGroupRoleMapper - { - public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) - => Task.FromResult(new GroupRoleMapping(groups, Scope: null)); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs deleted file mode 100644 index b076de93..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -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; - -/// -/// Verifies translates app -/// outcomes into OpcUaUserAuthResult, resolves roles from the directory's groups -/// through the shared 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. -/// -public sealed class LdapOpcUaUserAuthenticatorTests -{ - /// 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. - [Fact] - public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups() - { - // 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(), null)); - var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.DisplayName.ShouldBe("Alice"); - result.Roles.ShouldBe(new[] { "Designer" }); - } - - /// 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. - [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()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.Roles.ShouldBe(new[] { "Administrator" }); - } - - /// 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. - [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), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.Roles.ShouldBe(new[] { "Administrator" }); - } - - /// Verifies that LDAP authentication failure returns Deny result with error text. - [Fact] - public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text() - { - var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty(), Array.Empty(), "Invalid username or password")); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None); - - result.Success.ShouldBeFalse(); - result.Error.ShouldBe("Invalid username or password"); - } - - /// Verifies that LDAP exceptions are converted to backend error denial results. - [Fact] - public async Task Authenticate_LDAP_exception_returns_backend_error_denial() - { - var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable")); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None); - - result.Success.ShouldBeFalse(); - result.Error.ShouldNotBeNull(); - result.Error.ShouldContain("backend"); - } - - /// Verifies that authentication falls back to username when LDAP omits display name. - [Fact] - public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name() - { - var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.DisplayName.ShouldBe("alice"); - } - - /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper. - private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) - { - var services = new ServiceCollection(); - services.AddScoped(_ => mapper); - return services.BuildServiceProvider().GetRequiredService(); - } - - /// Test fake implementation of LDAP authentication service. - private sealed class FakeLdap : ILdapAuthService - { - private readonly Func _handler; - - /// Initializes the fake with a fixed result that ignores the username. - /// The result to return for any authentication attempt. - public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_; - - /// Initializes the fake with a handler function for custom results. - /// The handler to invoke with the username to produce a result. - public FakeLdap(Func handler) => _handler = handler; - - /// Authenticates a user asynchronously via the handler function. - /// The username to authenticate. - /// The password (ignored by the fake). - /// Cancellation token for the operation. - public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - => Task.FromResult(_handler(username)); - } - - /// Test fake group→role mapper driven by a delegate over the supplied groups. - private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper - { - /// Maps groups to roles via the configured delegate; Scope is always null. - /// The LDAP groups to map. - /// The cancellation token. - public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) - => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null)); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs index d5f4d88c..674fc79f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs @@ -63,19 +63,17 @@ public sealed class LdapOptionsBindingTests } /// - /// The five S2 resilience keys bind from the real Security:Ldap section (03/S2). + /// ConnectionTimeoutMs binds from the real Security:Ldap section. The other four S2 + /// resilience keys were removed with LdapOpcUaUserAuthenticator in #520 — they bounded an + /// LDAP bind running inline on OPC UA session activation, and no remaining consumer does that. /// [Fact] - public void Binding_reads_resilience_keys() + public void Binding_reads_the_connection_timeout_key() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Security:Ldap:ConnectionTimeoutMs"] = "7000", - ["Security:Ldap:AuthTimeoutMs"] = "12000", - ["Security:Ldap:MaxConcurrentAuthentications"] = "5", - ["Security:Ldap:OutageFailureThreshold"] = "4", - ["Security:Ldap:OutageCooldownSeconds"] = "20", }) .Build(); @@ -83,23 +81,13 @@ public sealed class LdapOptionsBindingTests options.ShouldNotBeNull(); options.ConnectionTimeoutMs.ShouldBe(7000); - options.AuthTimeoutMs.ShouldBe(12000); - options.MaxConcurrentAuthentications.ShouldBe(5); - options.OutageFailureThreshold.ShouldBe(4); - options.OutageCooldownSeconds.ShouldBe(20); } - /// The resilience keys carry the documented defaults when absent from config. + /// The connection timeout carries its documented default when absent from config. [Fact] - public void Resilience_key_defaults_hold_when_absent() + public void Connection_timeout_default_holds_when_absent() { - var options = new LdapOptions(); - - options.ConnectionTimeoutMs.ShouldBe(10000); - options.AuthTimeoutMs.ShouldBe(15000); - options.MaxConcurrentAuthentications.ShouldBe(8); - options.OutageFailureThreshold.ShouldBe(3); - options.OutageCooldownSeconds.ShouldBe(15); + new LdapOptions().ConnectionTimeoutMs.ShouldBe(10000); } /// ConnectionTimeoutMs is projected onto the shared-library options so the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs index 9c5ff2b9..2f84f5e9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs @@ -220,79 +220,6 @@ public sealed class LdapOptionsValidatorTests Sut.Validate(null, options).Failed.ShouldBeTrue(); } - /// Enabled with a non-positive AuthTimeoutMs fails validation (03/S2). - [Fact] - public void Enabled_with_nonpositive_AuthTimeoutMs_fails() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - AuthTimeoutMs = 0, - }; - - Sut.Validate(null, options).Failed.ShouldBeTrue(); - } - - /// Enabled with a non-positive MaxConcurrentAuthentications fails validation (03/S2). - [Fact] - public void Enabled_with_nonpositive_MaxConcurrentAuthentications_fails() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - MaxConcurrentAuthentications = 0, - }; - - Sut.Validate(null, options).Failed.ShouldBeTrue(); - } - - /// A positive OutageFailureThreshold with a zero OutageCooldownSeconds fails — - /// an open circuit with no cooldown would probe on every call (03/S2). - [Fact] - public void OutageFailureThreshold_positive_with_zero_cooldown_fails() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - OutageFailureThreshold = 3, - OutageCooldownSeconds = 0, - }; - - Sut.Validate(null, options).Failed.ShouldBeTrue(); - } - - /// A zero OutageFailureThreshold (circuit disabled) passes regardless of the - /// cooldown value (03/S2). - [Fact] - public void OutageFailureThreshold_zero_disables_circuit_and_passes() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - OutageFailureThreshold = 0, - OutageCooldownSeconds = 0, - }; - - Sut.Validate(null, options).Succeeded.ShouldBeTrue(); - } - - /// Enabled with port 0 reports the port-range failure using the shared primitive wording. [Fact] public void Enabled_with_zero_port_fails() { From dd939d3c972939c82ea978d0b790940f838d6c58 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 13:20:14 -0400 Subject: [PATCH 06/10] feat(acl): anonymous becomes a normal grantable principal (#520 D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous sessions used to short-circuit in HandleImpersonation — "let the SDK's default validation stand" — so they always connected and always carried no roles. Now admission and authority are separated: admission -> OpcUa:AllowAnonymous, the HOST's decision (it owns token policies) authority -> the authenticator, resolving the reserved principal "anonymous" through the SAME grant path a key's principal goes through So anonymous can be granted WRITE, not merely tolerated as read-only, and it arrives at the downstream gates as the same RoleCarryingUserIdentity a key produces — which is the real payoff: no gate carries an anonymous-shaped branch, so there is no bypass path to get wrong. AllowAnonymous defaults to TRUE on purpose. Defaulting it false would silently deny every client without a key the moment this deploys, including ScadaBridge's Data Connection Layer in another repo. Making keys mandatory is a rollout decision, not an upgrade side effect. When false, the Anonymous token policy is withheld so a client is refused at DISCOVERY rather than only at activation, and activation is rejected independently in case a client attempts a token type the endpoint never advertised. Two fail-closed choices, both falsified: - A failure to read anonymous's grants DENIES rather than admitting with no roles. Admitting is the pre-#520 behaviour, and defaulting to it exactly when the server cannot determine what anonymous may do stops being harmless once reads are gated. Making it admit reds Anonymous_denies_when_grants_cannot_be_read. - A key may not authenticate as the reserved "anonymous" principal, or a credentialled client silently inherits whatever anonymous was granted. AuthenticateAnonymousAsync is a DEFAULT interface implementation returning admit-with-no-roles, i.e. exactly the pre-#520 behaviour, so NullOpcUaUserAuthenticator and every test double keep working rather than being forced to reimplement it — important because driver-only nodes run the Null one and would otherwise black out. Note a default interface method is only reachable through the interface-typed reference, which is how OpcUaApplicationHost holds it. An existing test asserted the old fall-through and correctly went red; it is rewritten to pin the new contract rather than deleted, with the reason recorded. OpcUaServer 366, Host 42 green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- CLAUDE.md | 6 + docs/security.md | 23 ++++ .../OpcUa/ApiKeyOpcUaUserAuthenticator.cs | 45 +++++++ .../OpcUa/ConfigDbApiKeyStore.cs | 20 ++- .../OpcUa/IApiKeyStore.cs | 9 ++ .../OpcUaApplicationHost.cs | 115 ++++++++++++++++-- .../Security/IOpcUaUserAuthenticator.cs | 37 ++++++ .../OpcUa/ApiKeyAuthenticationTests.cs | 89 ++++++++++++++ .../OpcUaApplicationHostImpersonationTests.cs | 35 +++++- .../OpcUaApplicationHostSecurityTests.cs | 14 +++ 10 files changed, 374 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb9b51f9..11bb5bc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -616,6 +616,12 @@ Two things to know before relying on any of this: honouring a narrower grant's flags while unable to honour its *scope* would turn "WriteOperate on Equipment X" into server-wide write authority. Read, Browse, HistoryRead and CreateMonitoredItems remain ungated for any admitted session, **including Anonymous**. +- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`, + preserving today's behaviour) controls *admission*; when admitted the session authenticates as the + reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as + the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may + **not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather + than admitting with no roles. - ⚠️ **A driver-only node has no key source yet.** `ConfigDbApiKeyStore` needs a ConfigDb, which Phase 4 removed from driver-only nodes; until the artifact-backed store lands they register `NullOpcUaUserAuthenticator` and deny every UserName token. Note `OtOpcUaServerHostedService` takes diff --git a/docs/security.md b/docs/security.md index a48a01cf..d1c0c1ee 100644 --- a/docs/security.md +++ b/docs/security.md @@ -193,6 +193,29 @@ of a credential was wrong. ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing `Enabled` takes effect on a node only once a new artifact reaches it. +### Anonymous is a normal principal + +Anonymous sessions are **admitted by default** (`OpcUa:AllowAnonymous`, default `true` — flipping the +default would silently deny every client that has no key the moment this code deploys, including +ScadaBridge). Set it to `false` to make keys mandatory: the Anonymous token policy is then not +advertised, so a client is refused at *discovery* rather than only at activation, and anonymous +activation is rejected independently in case a client attempts a token type the endpoint never offered. + +When admitted, an anonymous session is **not a special case** — it authenticates as the reserved +principal `anonymous`, and `NodeAcl` rows naming it grant permissions exactly as they would a key's +principal. It can therefore be granted **write**, not merely tolerated as read-only. It arrives at the +downstream gates as the same `RoleCarryingUserIdentity` a key produces, so no gate carries an +anonymous-shaped branch and there is no bypass path to get wrong. + +Two consequences worth knowing: + +- `anonymous` is **reserved**: an API key may not use it as its principal, or a credentialled client + would silently inherit whatever anonymous was granted. Rejected at authentication as well as at + authoring. +- If the anonymous principal's grants **cannot be read**, the session is **denied**, not admitted with + no roles. Admitting is the pre-#520 behaviour, and defaulting to it precisely when the server cannot + determine what anonymous may do stops being harmless once reads are gated. + ⚠️ **Node ACLs are still not enforced per node.** Until the enforcement stages land, a key's grants are collapsed into the two existing server-wide role gates, and **only `Cluster`-scoped grants count** — a narrower grant confers nothing rather than silently widening to server-wide authority. Reads, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs index 0415cc16..60fa56e0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +// Explicit: an unqualified Configuration.* inside this namespace binds to Host.Configuration, not to +// the ZB.MOM.WW.OtOpcUa.Configuration assembly. +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; @@ -120,6 +123,17 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator return OpcUaUserAuthResult.Deny(DenyMessage); } + if (string.Equals(lookup.Key.Principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase)) + { + // Defence in depth against a key authored with the reserved principal (authoring blocks it). + // Allowing it would let a credentialled client silently inherit whatever anonymous was + // granted, and vice versa — two identities that are meant to be distinct sharing one grant. + _log.LogError( + "ApiKeyAuth: denied — key {ApiKeyId} uses the reserved principal '{Principal}'", + apiKeyId, OpcUaPrincipals.Anonymous); + return OpcUaUserAuthResult.Deny(DenyMessage); + } + var roles = ApiKeyInterimRoleMapper.Map(lookup.Grants, lookup.Key.Principal); _log.LogInformation( @@ -128,4 +142,35 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator return OpcUaUserAuthResult.Allow(lookup.Key.Principal, roles); } + + /// + /// + /// Resolves the anonymous principal's grants through the same mapper a key's principal + /// goes through, so anonymous is authorized by ordinary ACL rows rather than by a special case. + /// Whether anonymous may connect at all is the host's decision + /// (OpcUa:AllowAnonymous) and is settled before this is called. + /// + public async Task AuthenticateAnonymousAsync(CancellationToken ct) + { + IReadOnlyList grants; + try + { + grants = await _store.FindGrantsAsync(OpcUaPrincipals.Anonymous, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + // Deny rather than admit-with-no-roles. Admitting would be the pre-#520 behaviour, and doing + // it precisely when we could not read what anonymous is allowed to do is the wrong default: + // once reads are gated (stage 6) a no-roles admission stops being harmless. + _log.LogError(ex, "ApiKeyAuth: key store threw resolving anonymous grants"); + return OpcUaUserAuthResult.Deny("Anonymous access is unavailable."); + } + + var roles = ApiKeyInterimRoleMapper.Map(grants, OpcUaPrincipals.Anonymous); + _log.LogInformation( + "ApiKeyAuth: anonymous admitted with roles [{Roles}]", + roles.Count == 0 ? "none" : string.Join(",", roles)); + + return OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, roles); + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs index f268f6f6..c1f83f14 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs @@ -43,15 +43,25 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore if (key is null) return null; + return new ApiKeyLookup(key, await LoadClusterGrantsAsync(db, key.Principal, ct).ConfigureAwait(false)); + } + + /// + public async Task> FindGrantsAsync(string principal, CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + return await LoadClusterGrantsAsync(db, principal, ct).ConfigureAwait(false); + } + + private static async Task> LoadClusterGrantsAsync( + OtOpcUaConfigDbContext db, string principal, CancellationToken ct) + { // Only cluster-scoped rows can confer an interim role (see ApiKeyInterimRoleMapper), so only // those are fetched. Stage 5 widens this to the full grant set for per-node evaluation. - var grants = await db.NodeAcls + return await db.NodeAcls .AsNoTracking() - .Where(a => a.Principal == key.Principal - && a.ScopeKind == NodeAclScopeKind.Cluster) + .Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster) .ToListAsync(ct) .ConfigureAwait(false); - - return new ApiKeyLookup(key, grants); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs index 67cfb2c1..6a01089b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs @@ -22,6 +22,15 @@ public interface IApiKeyStore /// The cancellation token. /// The key and its principal's grants, or null when no usable key matches. Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct); + + /// + /// Returns the ACL grants naming a principal, with no key involved. Used for the + /// anonymous principal, which has grants but obviously no credential. + /// + /// The principal to fetch grants for. + /// The cancellation token. + /// The matching ACL rows; empty when none. + Task> FindGrantsAsync(string principal, CancellationToken ct); } /// An API key resolved for authentication, with the ACL grants naming its principal. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 037feed2..a8172cac 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -51,6 +51,29 @@ public sealed class OpcUaApplicationHostOptions /// public string PkiStoreRoot { get; set; } = "pki"; + /// + /// Whether anonymous sessions may connect at all (#520). When the + /// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client + /// must present an API key. + /// + /// + /// + /// Defaults to deliberately, which preserves today's behaviour. + /// Flipping the default would silently deny every client that has no key the moment this + /// code deploys — including ScadaBridge's Data Connection Layer, which consumes this address + /// space from another repo. Turning it off is the deployment-time decision that makes keys + /// mandatory, and it belongs in the rollout once keys have been issued. + /// + /// + /// Note this controls admission, not authority. An admitted anonymous session is a + /// normal principal (anonymous) whose grants come from NodeAcl rows naming it, + /// exactly like a key's principal — there is no anonymous bypass branch in the evaluator. + /// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole + /// address space, because reads carry no authorization check yet. + /// + /// + public bool AllowAnonymous { get; set; } = true; + /// /// Transport-security profiles exposed by the server. The SDK publishes one endpoint /// descriptor per profile and clients choose at session open. Default = all three @@ -228,7 +251,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable } private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) => - HandleImpersonation(_userAuthenticator, args, _logger); + HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous); /// /// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting @@ -256,11 +279,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable internal static void HandleImpersonation( IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, - ILogger logger) + ILogger logger, + bool allowAnonymous = true) { + if (args.NewIdentity is AnonymousIdentityToken anonymousToken) + { + HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken); + return; + } + if (args.NewIdentity is not UserNameIdentityToken token) { - // Anonymous + X509 tokens — let the SDK's default validation stand. + // X509 tokens — let the SDK's default validation stand. return; } @@ -309,6 +339,66 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable token.UserName, string.Join(",", result.Roles)); } + /// + /// Admits or rejects an anonymous session, and — when admitted — attaches the roles the + /// anonymous principal has been granted (#520). + /// + /// + /// Before #520 this path simply returned, leaving the SDK's default identity in place: anonymous + /// always connected and carried no roles. Two things change. Anonymous can now be refused + /// outright, and when admitted it is a normal principal whose grants come from ACL rows, so it + /// can be given read or write like any key. The identity attached is the same + /// the UserName path uses, so every downstream gate reads + /// it identically and there is no anonymous-shaped branch in any of them. + /// + private static void HandleAnonymousImpersonation( + IOpcUaUserAuthenticator authenticator, + ImpersonateEventArgs args, + ILogger logger, + bool allowAnonymous, + AnonymousIdentityToken token) + { + if (!allowAnonymous) + { + logger.LogInformation( + "OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)"); + args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, + "Anonymous access is disabled on this server."); + return; + } + + OpcUaUserAuthResult result; + try + { + // Same synchronous bridge, and for the same irreducible reason, as the UserName path above. + result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch (Exception ex) + { + // Fail closed. An anonymous session whose grants could not be resolved must not fall through + // to the SDK default identity, because that would silently ADMIT it — the pre-#520 + // behaviour — at exactly the moment we could not determine what it is allowed to do. + logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw"); + args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, + "Anonymous access is unavailable."); + return; + } + + if (!result.Success) + { + logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error); + args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected, + result.Error ?? "Anonymous access is denied."); + return; + } + + args.Identity = new RoleCarryingUserIdentity(token, result.Roles); + logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})", + OpcUaPrincipals.Anonymous, + result.Roles.Count == 0 ? "no roles" : string.Join(",", result.Roles)); + } + /// /// Guarantees the application instance certificate exists in {PkiStoreRoot}/own. /// The SDK auto-creates a self-signed certificate the first time this is called on a fresh @@ -350,7 +440,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable { serverConfig.SecurityPolicies.Add(policy); } - foreach (var token in BuildUserTokenPolicies()) + foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous)) { serverConfig.UserTokenPolicies.Add(token); } @@ -442,13 +532,20 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// UserName logins when none is registered. /// /// The Anonymous and UserName token policies. - internal static IEnumerable BuildUserTokenPolicies() + internal static IEnumerable BuildUserTokenPolicies(bool allowAnonymous = true) { - yield return new UserTokenPolicy(UserTokenType.Anonymous) + // Withholding the policy is what makes the refusal visible to a client at DISCOVERY rather than + // only at activation: it simply is not offered an anonymous option. HandleImpersonation still + // rejects anonymous activation independently, because a client may attempt a token type the + // endpoint never advertised. + if (allowAnonymous) { - PolicyId = "anonymous", - SecurityPolicyUri = SecurityPolicies.None, - }; + yield return new UserTokenPolicy(UserTokenType.Anonymous) + { + PolicyId = "anonymous", + SecurityPolicyUri = SecurityPolicies.None, + }; + } yield return new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs index 1f136333..837fdf23 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs @@ -22,6 +22,43 @@ public interface IOpcUaUserAuthenticator /// The cancellation token. /// An OpcUaUserAuthResult indicating success or failure. Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct); + + /// + /// Resolves what an anonymous session is allowed to do (#520). Anonymous is a normal + /// principal — — not a special case, so it can be granted + /// read or write through ordinary ACL rows. + /// + /// + /// + /// This is asked only AFTER the host has decided anonymous sessions are admissible at all + /// (OpcUaApplicationHostOptions.AllowAnonymous). Admission is the host's decision; + /// authority is this seam's. + /// + /// + /// The default implementation admits anonymous with no roles, which is exactly the + /// behaviour before #520: an anonymous session connects, and every role-gated operation + /// (write, alarm ack) denies. Implementors that can resolve grants override it; the rest + /// keep the safe status quo rather than being forced to reimplement it. + /// + /// + /// The cancellation token. + /// The roles to attach to the anonymous session, or a denial. + Task AuthenticateAnonymousAsync(CancellationToken ct) => + Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty())); +} + +/// Reserved principal names for the OPC UA data plane (#520). +public static class OpcUaPrincipals +{ + /// + /// The principal an anonymous session authenticates as. NodeAcl rows naming it grant + /// anonymous clients permissions exactly as they would a key's principal. + /// + /// + /// Reserved: an API key must not be created with this principal, or a key would silently inherit + /// whatever anonymous was granted (and vice versa). Enforced at authoring time. + /// + public const string Anonymous = "anonymous"; } /// Outcome of a UserName authentication attempt. populates the session identity's role set. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs index 767ebacc..54e709f0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ApiKeyAuthenticationTests.cs @@ -168,6 +168,85 @@ public sealed class ApiKeyAuthenticationTests (await auth.AuthenticateUserNameAsync("apikey", presented, default)).Success.ShouldBeFalse(); } + // ---------- anonymous as a normal principal (D3) ---------- + + [Fact] + public async Task Anonymous_resolves_through_the_same_grant_path_as_a_key() + { + var (_, store) = SetUp(); + store.PrincipalGrants.Add(new NodeAcl + { + NodeAclId = "ACL-anon", + ClusterId = "MAIN", + Principal = OpcUaPrincipals.Anonymous, + ScopeKind = NodeAclScopeKind.Cluster, + PermissionFlags = NodePermissions.WriteOperate, + }); + + var result = await Auth(store).AuthenticateAnonymousAsync(default); + + result.Success.ShouldBeTrue(); + result.DisplayName.ShouldBe(OpcUaPrincipals.Anonymous); + // The point of D3: anonymous can be granted WRITE, not merely tolerated as read-only. + result.Roles.ShouldContain(OpcUaDataPlaneRoles.WriteOperate); + } + + [Fact] + public async Task Anonymous_with_no_grants_is_admitted_with_no_roles() + { + // Today's behaviour preserved: it connects, and every role-gated operation denies. + var (_, store) = SetUp(); + + var result = await Auth(store).AuthenticateAnonymousAsync(default); + + result.Success.ShouldBeTrue(); + result.Roles.ShouldBeEmpty(); + } + + [Fact] + public async Task Anonymous_denies_when_grants_cannot_be_read() + { + // Deliberately NOT admit-with-no-roles. That is the pre-#520 behaviour, and defaulting to it + // exactly when we cannot read what anonymous may do stops being harmless once reads are gated. + var result = await Auth(new ThrowingApiKeyStore()).AuthenticateAnonymousAsync(default); + + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task A_key_may_not_authenticate_as_the_reserved_anonymous_principal() + { + var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper); + var key = new ApiKey + { + ApiKeyId = apiKeyId, + Name = "sneaky", + SecretHash = hash, + Principal = OpcUaPrincipals.Anonymous, + Enabled = true, + CreatedUtc = DateTime.UtcNow, + }; + + var result = await Auth(new FakeApiKeyStore(key, [])).AuthenticateUserNameAsync("apikey", presented, default); + + // Otherwise a credentialled client silently inherits whatever anonymous was granted. + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task The_default_interface_implementation_admits_anonymous_with_no_roles() + { + // NullOpcUaUserAuthenticator (driver-only nodes, pre-boot) must keep the pre-#520 behaviour + // rather than denying anonymous outright, or turning this on would black out those nodes. + // Interface-typed on purpose: a default interface implementation is only reachable through the + // interface, which is how OpcUaApplicationHost holds it. + IOpcUaUserAuthenticator sut = NullOpcUaUserAuthenticator.Instance; + var result = await sut.AuthenticateAnonymousAsync(default); + + result.Success.ShouldBeTrue(); + result.Roles.ShouldBeEmpty(); + } + // ---------- helpers ---------- private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new() @@ -207,15 +286,25 @@ public sealed class ApiKeyAuthenticationTests { public string KeyId => key.ApiKeyId; + /// Grants returned for a bare principal lookup (the anonymous path). + public List PrincipalGrants { get; } = []; + public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => Task.FromResult(string.Equals(apiKeyId, key.ApiKeyId, StringComparison.Ordinal) ? new ApiKeyLookup(key, grants) : null); + + public Task> FindGrantsAsync(string principal, CancellationToken ct) => + Task.FromResult>( + [.. PrincipalGrants.Where(g => string.Equals(g.Principal, principal, StringComparison.OrdinalIgnoreCase))]); } private sealed class ThrowingApiKeyStore : IApiKeyStore { public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) => throw new InvalidOperationException("store is down"); + + public Task> FindGrantsAsync(string principal, CancellationToken ct) => + throw new InvalidOperationException("store is down"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs index 81e97547..5a9275c2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostImpersonationTests.cs @@ -68,19 +68,44 @@ public sealed class OpcUaApplicationHostImpersonationTests args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials"); } - /// Verifies anonymous identity tokens are passed through to the SDK default handler. + /// + /// An admitted anonymous session now carries a role-bearing identity (#520). + /// + /// + /// This test previously asserted the opposite — that anonymous fell through untouched to the SDK + /// default identity. That was the pre-#520 behaviour and is exactly what changed: anonymous is a + /// normal principal whose grants come from ACL rows, so it must arrive at the downstream gates as + /// the same a key produces, or every gate would need an + /// anonymous-shaped branch. + /// [Fact] - public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default() + public void HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity() { var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription()); var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty())); OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger.Instance); - // Handler leaves anonymous tokens untouched — no identity, no validation error. - args.Identity.ShouldBeNull(); args.IdentityValidationError.ShouldBeNull(); - authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator"); + args.Identity.ShouldBeOfType(); + // Still no UserName authentication — the anonymous path must not consult the credential seam. + authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the UserName authenticator"); + } + + /// + /// With anonymous disallowed the session is rejected outright rather than admitted with no roles. + /// + [Fact] + public void HandleImpersonation_anonymous_token_is_rejected_when_disallowed() + { + var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription()); + var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty())); + + OpcUaApplicationHost.HandleImpersonation( + authenticator, args, NullLogger.Instance, allowAnonymous: false); + + args.Identity.ShouldBeNull(); + args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected); } /// Verifies authenticator exceptions result in rejection with validation error. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs index 8a8bc293..bcb13bee 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostSecurityTests.cs @@ -88,6 +88,20 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable userName.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256); } + /// + /// With OpcUa:AllowAnonymous false the Anonymous policy is not advertised, so a client is + /// refused at discovery rather than only at activation — but UserName survives, which is what + /// makes the server key-only rather than unreachable (#520). + /// + [Fact] + public void BuildUserTokenPolicies_withholds_anonymous_when_disallowed() + { + var tokens = OpcUaApplicationHost.BuildUserTokenPolicies(allowAnonymous: false).ToList(); + + tokens.ShouldNotContain(t => t.TokenType == UserTokenType.Anonymous); + tokens.ShouldContain(t => t.TokenType == UserTokenType.UserName); + } + /// /// Verifies that StartAsync populates ServerConfiguration with all enabled security profiles. /// From 087a969ee61e207fbeeba341159bfdd2e5c5a5e3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 13:40:34 -0400 Subject: [PATCH 07/10] =?UTF-8?q?feat(acl):=20API=20key=20minting=20?= =?UTF-8?q?=E2=80=94=20AdminUI=20page=20+=20admin=20service=20(#520)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulled forward from stage 7 because without it the feature is unusable and un-live-gateable: there was no way to create a key short of hand-inserting a row and computing an HMAC by hand. The minting logic is an IApiKeyAdminService in Core.Authorization, NOT logic in the Razor page — this repo has no bUnit, so anything only a page can reach is untestable, and minting credentials is not where that belongs. The page is a thin shell over it. Moved to Core.Authorization so both ends can reach them: ApiKeyOptions (was in Host) and OpcUaPrincipals (was in OpcUaServer.Security). The OPC UA server resolves principals; the AdminUI must refuse to mint a key claiming a reserved one. OpcUaServer already gained a Core reference in stage 1b, so no new edge — AdminUI gains one. ApiKeyOptions is now bound UNCONDITIONALLY rather than in the driver branch: a driver node needs the pepper to VERIFY keys and an admin node needs it to MINT them, and getting that wrong on either side is a silent authentication failure rather than a startup error. Validation rules, all covered: the reserved 'anonymous' principal is refused case-insensitively (so "ANONYMOUS" cannot slip past into a distinct principal that the evaluator would then match case-INsensitively against anonymous's grants); blank name/principal; a principal longer than the column, rejected rather than truncated, because silent truncation yields a key that authenticates and is granted nothing with no visible cause; a past expiry. Generation runs BEFORE the DbContext is opened so an unconfigured pepper surfaces as a clear error instead of a stored row whose hash nothing can verify. The page shows the plaintext ONCE and says so; it is held in component state and written nowhere else, because the server keeps only a hash. Revocation is flagged in the UI as taking effect only after the next deployment — driver-only nodes have no ConfigDb, so keys ride the artifact. FleetAdmin-only (Administrator), not ConfigEditor: this mints credentials. Core.Tests 68 green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- .../Authorization/ApiKeyAdminService.cs | 141 ++++++++++++ .../Authorization}/ApiKeyOptions.cs | 2 +- .../Authorization/OpcUaPrincipals.cs | 21 ++ .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/ApiKeys.razor | 206 ++++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.AdminUI.csproj | 3 + src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 13 +- .../OpcUaApplicationHost.cs | 1 + .../Security/IOpcUaUserAuthenticator.cs | 15 +- .../Authorization/ApiKeyAdminServiceTests.cs | 84 +++++++ 10 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyAdminService.cs rename src/{Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa => Core/ZB.MOM.WW.OtOpcUa.Core/Authorization}/ApiKeyOptions.cs (96%) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/OpcUaPrincipals.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeyAdminServiceTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyAdminService.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyAdminService.cs new file mode 100644 index 00000000..33fb825e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyAdminService.cs @@ -0,0 +1,141 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// Create / list / revoke OPC UA data-plane API keys (#520). +/// +/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a +/// page can reach is untestable, and minting credentials is not where that belongs. +/// +public interface IApiKeyAdminService +{ + /// Lists every key, newest first. Never returns a secret — there is none stored. + /// The cancellation token. + /// All key rows. + Task> ListAsync(CancellationToken ct); + + /// + /// Mints a new key. The returned plaintext is the ONLY time it exists — it is not recoverable + /// from what is stored, so a caller that fails to show it has effectively discarded the key. + /// + /// Human label, e.g. the consuming system. + /// The principal the key authenticates as. + /// Optional expiry. + /// Optional operator notes. + /// The cancellation token. + /// The presented key, shown once. + Task CreateAsync( + string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct); + + /// Enables or disables a key. + /// The public key id. + /// Whether the key may authenticate. + /// The cancellation token. + /// A task representing the operation. + Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct); + + /// Permanently deletes a key row. + /// The public key id. + /// The cancellation token. + /// A task representing the operation. + Task DeleteAsync(string apiKeyId, CancellationToken ct); +} + +/// EF-backed . +public sealed class ApiKeyAdminService : IApiKeyAdminService +{ + /// Longest accepted principal name, matching the column width. + public const int MaxPrincipalLength = 256; + + private readonly IDbContextFactory _factory; + private readonly string _pepper; + + /// Initializes a new instance of the class. + /// The config DB context factory. + /// The server-side pepper used to hash key secrets. + public ApiKeyAdminService(IDbContextFactory factory, string pepper) + { + _factory = factory; + _pepper = pepper; + } + + /// + public async Task> ListAsync(CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + return await db.ApiKeys.AsNoTracking().OrderByDescending(k => k.CreatedUtc).ToListAsync(ct) + .ConfigureAwait(false); + } + + /// + public async Task CreateAsync( + string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct) + { + name = (name ?? string.Empty).Trim(); + principal = (principal ?? string.Empty).Trim(); + + if (name.Length == 0) throw new ArgumentException("A key name is required.", nameof(name)); + if (principal.Length == 0) + throw new ArgumentException("A principal is required.", nameof(principal)); + if (principal.Length > MaxPrincipalLength) + throw new ArgumentException( + $"Principal must be {MaxPrincipalLength} characters or fewer.", nameof(principal)); + + // Reserved: a key authenticating as 'anonymous' would silently inherit whatever anonymous was + // granted, and vice versa — two identities meant to be distinct sharing one grant set. The + // authenticator refuses it too; this is the authoring half so it can never be stored at all. + if (string.Equals(principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException( + $"'{OpcUaPrincipals.Anonymous}' is reserved for anonymous sessions and cannot be a key's principal.", + nameof(principal)); + + if (expiresUtc is { } exp && exp <= DateTime.UtcNow) + throw new ArgumentException("Expiry must be in the future.", nameof(expiresUtc)); + + // Generate BEFORE opening the context: this throws on an unconfigured pepper, and failing there + // is much clearer than a row that persists against a hash nothing can ever verify. + var (presented, apiKeyId, hash) = ApiKeySecret.Generate(_pepper); + + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + db.ApiKeys.Add(new ApiKey + { + ApiKeyId = apiKeyId, + Name = name, + SecretHash = hash, + Principal = principal, + Enabled = true, + CreatedUtc = DateTime.UtcNow, + ExpiresUtc = expiresUtc, + Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(), + }); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + + return presented; + } + + /// + public async Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct) + .ConfigureAwait(false); + if (key is null) return; + + key.Enabled = enabled; + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(string apiKeyId, CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct) + .ConfigureAwait(false); + if (key is null) return; + + db.ApiKeys.Remove(key); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs similarity index 96% rename from src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs rename to src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs index d6e384bf..47d122e4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs @@ -1,4 +1,4 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; /// /// Security:ApiKeys — OPC UA data-plane API key settings (#520). The data plane is diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/OpcUaPrincipals.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/OpcUaPrincipals.cs new file mode 100644 index 00000000..3ddc165e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/OpcUaPrincipals.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// Reserved principal names for the OPC UA data plane (#520). +/// +/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server +/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key +/// that claims it. +/// +public static class OpcUaPrincipals +{ + /// + /// The principal an anonymous session authenticates as. NodeAcl rows naming it grant + /// anonymous clients permissions exactly as they would a key's principal. + /// + /// + /// Reserved: an API key must not use this principal, or a credentialled client would silently + /// inherit whatever anonymous was granted (and vice versa). Refused at authoring AND at + /// authentication — belt and braces, because the two paths can be reached independently. + /// + public const string Anonymous = "anonymous"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index 29be26aa..0827d6be 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -20,6 +20,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor new file mode 100644 index 00000000..e96e0b8a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor @@ -0,0 +1,206 @@ +@page "/apikeys" +@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)] +@rendermode RenderMode.InteractiveServer +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Core.Authorization +@inject IApiKeyAdminService Keys + +
+

OPC UA API keys

+
+ + + +@if (_created is not null) +{ + +} + +@if (!string.IsNullOrWhiteSpace(_error)) +{ + +} + +
+
Create a key
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
Keys
+ @if (_keys is null) + { +
Loading…
+ } + else if (_keys.Count == 0) + { +
+ No keys yet. Until one exists, only anonymous sessions can connect. +
+ } + else + { + + + + + + + + + + + + + + @foreach (var k in _keys) + { + + + + + + + + + + } + +
NamePrincipalKey idStatusCreated (UTC)Expires (UTC)
@k.Name@k.Principal@k.ApiKeyId + @if (!k.Enabled) + { + revoked + } + else if (k.ExpiresUtc is { } e && e <= DateTime.UtcNow) + { + expired + } + else + { + active + } + @k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—") + + +
+ } +
+ +@code { + private IReadOnlyList? _keys; + private string _newName = ""; + private string _newPrincipal = ""; + private DateTime? _newExpires; + private string? _created; + private string? _error; + private bool _busy; + + protected override async Task OnInitializedAsync() => await ReloadAsync(); + + private async Task ReloadAsync() + { + try + { + _keys = await Keys.ListAsync(default); + } + catch (Exception ex) + { + _error = ex.Message; + _keys = []; + } + } + + private async Task CreateAsync() + { + _busy = true; + _error = null; + _created = null; + try + { + // Shown once, held only in this component's state. There is no way to recover it afterwards + // — the server stores a hash — so it is deliberately not written anywhere else. + _created = await Keys.CreateAsync( + _newName, + _newPrincipal, + // A date-only input means midnight; treat it as UTC rather than local so an operator + // near a timezone boundary does not get a key that expired before it was issued. + _newExpires is { } d ? DateTime.SpecifyKind(d, DateTimeKind.Utc) : null, + notes: null, + default); + _newName = ""; + _newPrincipal = ""; + _newExpires = null; + await ReloadAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task SetEnabledAsync(string apiKeyId, bool enabled) + { + _busy = true; + _error = null; + try + { + await Keys.SetEnabledAsync(apiKeyId, enabled, default); + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync(string apiKeyId) + { + _busy = true; + _error = null; + try + { + await Keys.DeleteAsync(apiKeyId, default); + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 4b1727c9..c3b46211 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -23,6 +23,9 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 2efba25b..ef6c3c62 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Serilog; using ZB.MOM.WW.LocalDb.Replication; @@ -20,6 +21,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Host; using ZB.MOM.WW.OtOpcUa.Host.Configuration; @@ -132,6 +134,11 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua"); // central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it // unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string. // A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected. +// #520 — bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it +// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a +// startup error. +builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); + if (hasAdmin) builder.Services.AddOtOpcUaConfigDb(builder.Configuration); else @@ -344,7 +351,6 @@ if (hasDriver) // (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP // registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP. // Only IOpcUaUserAuthenticator moves. - builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); if (hasAdmin) { // ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin. @@ -353,6 +359,11 @@ if (hasDriver) // Safe direction, and anonymous sessions are unaffected. builder.Services.AddSingleton(); builder.Services.AddSingleton(); + + // Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb. + builder.Services.AddSingleton(sp => new ApiKeyAdminService( + sp.GetRequiredService>(), + sp.GetRequiredService>().Value.Pepper)); } else { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index a8172cac..aa9f8784 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Configuration; using Opc.Ua.Server; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs index 837fdf23..f27d111d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; /// @@ -47,19 +49,6 @@ public interface IOpcUaUserAuthenticator Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty())); } -/// Reserved principal names for the OPC UA data plane (#520). -public static class OpcUaPrincipals -{ - /// - /// The principal an anonymous session authenticates as. NodeAcl rows naming it grant - /// anonymous clients permissions exactly as they would a key's principal. - /// - /// - /// Reserved: an API key must not be created with this principal, or a key would silently inherit - /// whatever anonymous was granted (and vice versa). Enforced at authoring time. - /// - public const string Anonymous = "anonymous"; -} /// Outcome of a UserName authentication attempt. populates the session identity's role set. public sealed record OpcUaUserAuthResult( diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeyAdminServiceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeyAdminServiceTests.cs new file mode 100644 index 00000000..2e082044 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeyAdminServiceTests.cs @@ -0,0 +1,84 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization; + +/// +/// Validation rules for minting API keys (#520). These run BEFORE any database work, so they are +/// covered without a ConfigDb: a null context factory proves the guard rejected the input rather +/// than reaching persistence (it would NullReferenceException if it got that far). +/// +public sealed class ApiKeyAdminServiceTests +{ + private static ApiKeyAdminService Sut(string pepper = "unit-test-pepper") => new(null!, pepper); + + [Fact] + public async Task The_reserved_anonymous_principal_cannot_be_minted_as_a_key() + { + // Otherwise a credentialled client silently inherits whatever anonymous was granted. Rejected + // here AND at authentication, because the two paths are reachable independently. + var ex = await Should.ThrowAsync(() => + Sut().CreateAsync("sneaky", OpcUaPrincipals.Anonymous, null, null, default)); + + ex.Message.ShouldContain("reserved"); + } + + [Fact] + public async Task The_reserved_principal_check_is_case_insensitive() + { + // "Anonymous" must not slip past a case-sensitive comparison and become a distinct principal + // that the evaluator would then match case-INsensitively against anonymous's grants. + await Should.ThrowAsync(() => + Sut().CreateAsync("sneaky", "ANONYMOUS", null, null, default)); + } + + [Theory] + [InlineData("", "principal")] + [InlineData(" ", "principal")] + public async Task A_blank_principal_is_rejected(string principal, string expectedParam) + { + var ex = await Should.ThrowAsync(() => + Sut().CreateAsync("name", principal, null, null, default)); + + ex.ParamName.ShouldBe(expectedParam); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task A_blank_name_is_rejected(string name) + { + var ex = await Should.ThrowAsync(() => + Sut().CreateAsync(name, "some-principal", null, null, default)); + + ex.ParamName.ShouldBe("name"); + } + + [Fact] + public async Task A_principal_longer_than_the_column_is_rejected_rather_than_truncated() + { + // Silent truncation would create a key whose principal does not match the ACL row the operator + // authored, i.e. a key that authenticates and is granted nothing, with no visible cause. + var tooLong = new string('p', ApiKeyAdminService.MaxPrincipalLength + 1); + + await Should.ThrowAsync(() => + Sut().CreateAsync("name", tooLong, null, null, default)); + } + + [Fact] + public async Task An_expiry_in_the_past_is_rejected() + { + await Should.ThrowAsync(() => + Sut().CreateAsync("name", "p", DateTime.UtcNow.AddMinutes(-1), null, default)); + } + + [Fact] + public async Task An_unconfigured_pepper_fails_before_anything_is_persisted() + { + // Generate() runs before the DbContext is opened precisely so this surfaces as a clear pepper + // error rather than a stored row whose hash nothing could ever verify. + await Should.ThrowAsync(() => + Sut(pepper: "").CreateAsync("name", "p", null, null, default)); + } +} From 54b06aab3375e14d60a4dcac49097815e5320fd9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 13:47:13 -0400 Subject: [PATCH 08/10] test(acl): docker-dev pepper + live gate for the API-key identity path (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Security__ApiKeys__Pepper anchor to docker-dev, merged into all six host nodes — a driver node needs it to verify keys, an admin node to mint them, and a mismatch is a silent authentication failure rather than a startup error. Committed dev value under the same exception as the LDAP service account and the deploy key. Live gate on the rig (migrations applied, both central nodes rebuilt one at a time to avoid the simultaneous-cold-start split): - Migrator applied the rename + ApiKey migrations; NodeAcl.Principal exists, LdapGroup is gone, ApiKey created. - sp_ComputeGenerationDiff EXECUTES and returns rows. Without the re-emit in the rename migration this call would have failed with Invalid column name, so the trap is confirmed closed rather than argued closed. - A key whose hash was computed by an INDEPENDENT Python HMAC implementation authenticates against the C# verifier. That validates the hashing contract (otua__, peppered HMAC-SHA256, base64) far better than round-tripping my own code would. - Wrong secret -> 0x80210000 BadIdentityTokenRejected. - Key with a cluster-scoped WriteOperate grant -> log shows "granted key gate-key as principal gate-principal with roles [WriteOperate]" and the WRITE SUCCEEDS. - Same node, anonymous -> "admitted with roles [none]", write denied 0x801F0000 BadUserAccessDenied. - D3's distinctive claim proven: adding a cluster-scoped WriteOperate row for principal 'anonymous' flips the SAME anonymous session to "admitted with roles [WriteOperate]" and its write succeeds. No code change, no restart. - OpcUa__AllowAnonymous=false -> anonymous fails with "Endpoint does not support the user identity type provided", i.e. refused at DISCOVERY because the token policy is withheld, not merely rejected at activation; the keyed read still works, so the server is key-only rather than unreachable. NOT verified: the /apikeys Razor page itself. The Chrome extension was not connected, and this repo has no bUnit, so the page is exactly the kind of surface that has bitten before (#505). The service beneath it is unit-tested and the whole path below it is now live-proven, but the page needs a /run pass before this can be called done. Rig restored: override removed, gate key and both ACL rows deleted, anonymous reads Good again. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- docker-dev/docker-compose.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 0dfe2a73..89b9af91 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -137,6 +137,14 @@ x-secrets-env: &secrets-env # AllowInsecure=false ⇒ OptionsValidationException), so a node that carries no LDAP config crashes at boot. # This anchor is merged into every host node's environment (via `<<: [*secrets-env, *ldap-env]`). Central # still also lists the block inline for readability; the explicit keys win over the merge, same values. +# #520 — OPC UA data-plane API keys. The pepper is merged into every host node: a driver node needs it +# to VERIFY keys, an admin node to MINT them, and a mismatch between the two is a silent authentication +# failure. Committed dev value only, under the same committed-dev-secret exception as the LDAP service +# account and the deploy key below it; a real deployment supplies Security__ApiKeys__Pepper via env or +# a ${secret:} reference and NEVER commits it. Rotating it invalidates every existing key. +x-apikeys-env: &apikeys-env + Security__ApiKeys__Pepper: "docker-dev-apikey-pepper-not-for-production" + x-ldap-env: &ldap-env Security__Ldap__Enabled: "true" Security__Ldap__DevStubMode: "false" @@ -268,7 +276,7 @@ services: sql: { condition: service_healthy } migrator: { condition: service_completed_successfully } environment: - <<: [*secrets-env, *ldap-env] + <<: [*secrets-env, *ldap-env, *apikeys-env] OTOPCUA_ROLES: "admin,driver,cluster-MAIN" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" @@ -393,7 +401,7 @@ services: central-1: { condition: service_started } migrator: { condition: service_completed_successfully } environment: - <<: [*secrets-env, *ldap-env] + <<: [*secrets-env, *ldap-env, *apikeys-env] OTOPCUA_ROLES: "admin,driver,cluster-MAIN" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" @@ -548,7 +556,7 @@ services: MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}" MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053" MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053" - <<: [*secrets-env, *ldap-env] + <<: [*secrets-env, *ldap-env, *apikeys-env] # Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/ # mem_reservation are inherited from the *otopcua-host anchor. Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" @@ -649,7 +657,7 @@ services: MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}" MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053" MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053" - <<: [*secrets-env, *ldap-env] + <<: [*secrets-env, *ldap-env, *apikeys-env] Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" @@ -737,7 +745,7 @@ services: MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}" MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053" MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053" - <<: [*secrets-env, *ldap-env] + <<: [*secrets-env, *ldap-env, *apikeys-env] Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" @@ -809,7 +817,7 @@ services: MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-ClusterClient}" MeshTransport__CentralContactPoints__0: "akka.tcp://otopcua@central-1:4053" MeshTransport__CentralContactPoints__1: "akka.tcp://otopcua@central-2:4053" - <<: [*secrets-env, *ldap-env] + <<: [*secrets-env, *ldap-env, *apikeys-env] Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" From c1771dc271bd1c0f7ce0ce88fb3152856e1da588 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 14:06:13 -0400 Subject: [PATCH 09/10] docs(acl): correct the API-key revocation warning after live verification (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /apikeys page claimed revocation "takes effect on a node only after the next deployment". The live gate showed that is wrong for the node the operator is most likely looking at: revoking through the UI denied the key on the VERY NEXT request against the fused admin+driver node, because ConfigDbApiKeyStore reads per request. The deployment delay applies only to driver-only nodes, whose keys ride the artifact. Reworded to say both, and to tell an operator to assume the slower behaviour when revoking a compromised key — which is the decision the warning exists to inform. Found by doing the thing rather than reasoning about it: the previous wording looked obviously correct until the revoked key was actually re-presented. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- .../Components/Pages/ApiKeys.razor | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor index e96e0b8a..8fc31a67 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor @@ -15,8 +15,11 @@ whose password is the key below. The key resolves to a principal; ACL rows naming that principal are what grant it permissions.
- Revoking a key takes effect on a node only after the next deployment — driver-only - nodes have no config database, so keys travel inside the deployment artifact. + Revocation is not uniform across the fleet. A node with a config database (a fused + admin+driver node such as this one) applies it immediately, because it reads keys per + request. A driver-only node has no config database, so its keys travel inside the + deployment artifact and a revocation reaches it only on the next deployment. + Assume the slower behaviour when revoking a compromised key. @if (_created is not null) From a438ac16402396537f92f8c5a0c02cd0d493386e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 31 Jul 2026 14:37:18 -0400 Subject: [PATCH 10/10] feat(acl): keys + ACLs reach driver-only nodes via the artifact (#520 stage 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A driver-only node has had no ConfigDb since per-cluster mesh Phase 4, so it registered NullOpcUaUserAuthenticator and denied every UserName token. Its keys now ride the deployment artifact instead. - ConfigComposer snapshots ApiKeys alongside the NodeAcls it already carried. Only the peppered HMAC ships, never a secret, and the pepper is node config that is deliberately not in the artifact. - DeploymentArtifact.ParseDataPlaneAuthority decodes both node-side. Grants are scoped to the node's own cluster; keys are not, because ApiKey carries no ClusterId — a key is a fleet-wide credential and the ACL rows are what bind it. - OpcUaPublishActor installs them on DataPlaneAuthorityState on every apply, BEFORE its plan.IsEmpty early-return: an artifact that only revokes a key produces an empty address-space plan, so feeding after that return would leave the revoked key working until an unrelated structural change redeployed. - ArtifactApiKeyStore serves them to session activation; a node with a ConfigDb keeps ConfigDbApiKeyStore, whose per-request read is what makes revocation immediate there. - OtOpcUaNodeManager gains DataPlaneAuthority — the cluster identity blocker #4 named. NodeScope.ClusterId is required and the evaluator fails closed on a mismatch, so until now no scope it built could have evaluated. Nothing reads it until stages 5/6. The generation is a node-local COUNTER driven by the RevisionHash, not derived from its bits. PermissionTrieCache treats the highest GenerationId as current, so a generation taken from hash bytes would install a lower number about half the time and the cache would go on serving the previous deployment's ACLs, silently. Sessions are not stamped with it yet — that belongs with the first seam that reads a UserAuthorizationState, in stage 5, rather than being built now and consulted by nothing. Live-gated on docker-dev against site-a-1 (driver-only, no ConfigDb): key authenticates, wrong secret denied, both halves of the revocation asymmetry observed, and a SITE-A grant confirmed not to reach site-b-1. Key minted with an independent Python HMAC so the verifier was checked against something other than itself. Rig left clean. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo --- CLAUDE.md | 22 +- ...026-07-31-apikey-acl-enforcement-design.md | 59 +++- docs/security.md | 42 ++- .../Authorization/DataPlaneAuthority.cs | 235 +++++++++++++++ .../Authorization/UserAuthorizationState.cs | 11 +- .../AdminOperations/ConfigComposer.cs | 7 + .../OpcUa/ArtifactApiKeyStore.cs | 80 ++++++ .../OpcUa/ConfigDbApiKeyStore.cs | 12 +- .../OpcUa/IApiKeyStore.cs | 10 +- .../OpcUa/OtOpcUaServerHostedService.cs | 17 ++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 41 ++- .../OtOpcUaNodeManager.cs | 33 +++ .../OtOpcUaSdkServer.cs | 23 ++ .../Drivers/DeploymentArtifact.cs | 182 ++++++++++++ .../OpcUa/OpcUaPublishActor.cs | 66 ++++- .../ServiceCollectionExtensions.cs | 9 +- .../ZB.MOM.WW.OtOpcUa.Runtime.csproj | 4 + .../DataPlaneAuthorityStateTests.cs | 190 ++++++++++++ .../DataPlaneAuthorityArtifactTests.cs | 272 ++++++++++++++++++ .../OpcUa/ArtifactApiKeyStoreTests.cs | 109 +++++++ ...ploymentArtifactDataPlaneAuthorityTests.cs | 129 +++++++++ 21 files changed, 1505 insertions(+), 48 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/DataPlaneAuthorityStateTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/DataPlaneAuthorityArtifactTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/ArtifactApiKeyStoreTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactDataPlaneAuthorityTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 11bb5bc3..9947f054 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -622,11 +622,23 @@ Two things to know before relying on any of this: the same `RoleCarryingUserIdentity` a key produces. Set it `false` to make keys mandatory. A key may **not** use the `anonymous` principal, and a failure to read anonymous's grants **denies** rather than admitting with no roles. -- ⚠️ **A driver-only node has no key source yet.** `ConfigDbApiKeyStore` needs a ConfigDb, which Phase 4 - removed from driver-only nodes; until the artifact-backed store lands they register - `NullOpcUaUserAuthenticator` and deny every UserName token. Note `OtOpcUaServerHostedService` takes - `IOpcUaUserAuthenticator` as a **required** ctor parameter — leaving it unregistered fails DI and the - node does not boot, so the deny-all must be registered explicitly. +- **Keys reach a driver-only node inside the deployment artifact** (stage 4). `ConfigComposer` snapshots + the `ApiKey` rows — the peppered HMAC only, never a secret, and the pepper is node config that is + deliberately *not* in the artifact — and `DeploymentArtifact.ParseDataPlaneAuthority` decodes them + node-side alongside the cluster's `NodeAcl` rows. `OpcUaPublishActor` installs both on + `IDataPlaneAuthoritySource` on every apply, **before** its `plan.IsEmpty` early-return, so a deployment + that only revokes a key still lands. A node with a ConfigDb keeps `ConfigDbApiKeyStore` (per-request + read); a driver-only node gets `ArtifactApiKeyStore`. ⚠️ **The consequence is asymmetric revocation: + immediate on a ConfigDb node, next-deployment on a driver-only one.** +- ⚠️ **The auth generation is a node-local COUNTER driven by the `RevisionHash`, not derived from its + bits.** `PermissionTrieCache` treats the highest `GenerationId` as current, so a generation taken from + hash bytes would install a *lower* number about half the time and the cache would go on serving the + previous deployment's ACLs with nothing logged. Pinned by + `DataPlaneAuthorityStateTests.The_generation_only_ever_increases_even_when_the_hash_decreases`. +- Note `OtOpcUaServerHostedService` takes `IOpcUaUserAuthenticator` **and** `IDataPlaneAuthoritySource` + as **required** ctor parameters — the built-in DI container does not fall back to a default value for + an unregistered service, so both must be registered explicitly on every node (deny-all / empty on a + node with neither role) or it does not boot. Remaining stages, and the rationale for every decision above, are in `docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide. diff --git a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md index d37daf25..7c2e9c00 100644 --- a/docs/plans/2026-07-31-apikey-acl-enforcement-design.md +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -110,23 +110,68 @@ mismatch). Each stage is one commit, builds clean, and leaves the tree shippable. -1. **Foundation — principal model.** `NodeAcl.LdapGroup` → `Principal` (+ migration); `ApiKey` entity +1. ✅ **Foundation — principal model.** `NodeAcl.LdapGroup` → `Principal` (+ migration); `ApiKey` entity + store + hashing; `OpcUaServer.csproj` references `Core`. No behaviour change. -2. **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the +2. ✅ **Identity cutover.** `ApiKeyOpcUaUserAuthenticator` replaces `LdapOpcUaUserAuthenticator` on the OPC UA data plane; Anonymous resolves to the `anonymous` principal or is rejected per setting. AdminUI LDAP untouched. -3. **Raw scope.** `NodeHierarchyKind.Raw` + RawPath scope paths; `scopePaths` producer built from the +3. **Raw scope.** *(not started — sequenced after stage 4 at the owner's request)* `NodeHierarchyKind.Raw` + RawPath scope paths; `scopePaths` producer built from the artifact; coverage for the production hierarchy path that today has none. -4. **Artifact + lifecycle.** Node-side ACL/key parsing out of the artifact; cluster identity on the - node manager; `AuthGenerationId` from `RevisionHash`. +4. **Artifact + lifecycle.** ✅ **Done.** `ConfigComposer` snapshots `ApiKeys` alongside the `NodeAcls` + it already carried; `DeploymentArtifact.ParseDataPlaneAuthority` decodes both node-side (grants + scoped to the node's own cluster, keys not — a key is a fleet-wide credential and the ACL rows are + what bind it); `OpcUaPublishActor` installs them on `DataPlaneAuthorityState` on every apply; + `ArtifactApiKeyStore` serves them to session activation on a driver-only node, which until now + denied every UserName token; and `OtOpcUaNodeManager` gains a `DataPlaneAuthority` property, which + is the cluster identity blocker #4 named. + + Two things went differently from the sketch, both deliberately: + + - **The generation is a counter, not the hash.** "Stamp the generation from the `RevisionHash`" + reads as "derive a long from the hash bytes", and that is wrong: `PermissionTrieCache` treats the + highest `GenerationId` as current (`ClusterEntry.WithAdditional`) and `Prune` keeps the highest, + while hash fragments are unordered — so roughly half of all deploys would install a *lower* + generation and the cache would keep serving the previous deployment's ACLs, silently. The hash + still *drives* the stamp (a new hash advances it, a re-applied one does not); it just does not + *become* it. + - **Sessions are not stamped yet.** Building a `UserAuthorizationState` per session belongs with the + first seam that reads one, in stage 5. Constructing one now would be a per-session object nothing + consults — the dormant-wiring failure mode this repo has been bitten by before. + + Also settled here: the feed runs **before** `OpcUaPublishActor`'s `plan.IsEmpty` early-return. An + artifact that only revokes a key produces an empty address-space plan, so feeding after that return + would leave the revoked key working until some unrelated structural change happened to redeploy. 5. **Enforce write + alarms + HistoryRead** behind `Security:Acl:Enabled`, using the seams that - already exist. + already exist. Includes building the per-session `UserAuthorizationState` and stamping it with the + generation stage 4 now supplies, plus widening both `IApiKeyStore` implementations from + Cluster-scoped grants to the full grant set (they narrow today because + `ApiKeyInterimRoleMapper` cannot honour scope). 6. **Enforce read/browse/subscribe** — the new seam, with per-session decision caching. -7. **AdminUI.** Key management (create/revoke, show-once), ACL authoring against principals, and +7. **AdminUI.** Key management (create/revoke, show-once) ✅ **done early, in stage 4's predecessor**; ACL authoring against principals, and **remove the two "not enforced" warning banners** — which is the honest signal that this is done. 8. **Rollout.** Docs, the ScadaBridge key hand-off, and flipping `Security:Acl:Enabled` on docker-dev as the live gate. +## Stage 4 live gate — PASSED (docker-dev, 2026-07-31) + +Run against the six-node docker-dev rig, with **site-a-1** as the subject: `Cluster:Roles = +driver,cluster-SITE-A`, no `ConnectionStrings:ConfigDb`, `ConfigSource:Mode=FetchAndCache`. Before this +stage that node registered `NullOpcUaUserAuthenticator` and denied every UserName token. + +The key was minted with an **independent Python HMAC** rather than by the C# minter, so the verifier was +checked against something other than itself. + +| # | Leg | Result | +|---|---|---| +| 1 | Boot from LocalDb cache installs an authority generation | `generation 1 installed (cluster=SITE-A, revision=3f4a908c, keys=0, grants=0)` — cluster identity resolved with no ConfigDb | +| 2 | Deploy a key + a SITE-A cluster grant | `generation 2 installed (revision=cb165478, keys=1, grants=1)`; revision matches the deploy response | +| 3 | The key authenticates **on the driver-only node** | `ApiKeyAuth: granted key Stage4 Live Gate (a1b2c3d4e5f60011) as principal stage4-gate with roles [WriteOperate]` | +| 4 | A wrong secret is denied there | `denied — secret mismatch for key id a1b2c3d4e5f60011` | +| 5 | **Revocation asymmetry, both halves** | `Enabled=0` in SQL, no deploy: central-1 denies on the next request (`no enabled, unexpired key…`) while site-a-1 still accepts. After deploying: `generation 3`, and site-a-1 denies. This is the documented sharp edge, observed rather than asserted. | +| 6 | Grants do not leak across clusters | site-b-1 installed `keys=1, grants=0` from the same artifact — it received the key but not SITE-A's grant | + +Rig left clean (0 keys, 0 ACL rows, redeployed, all six nodes healthy). + ## Live gate (stage 8) On docker-dev, with `Security:Acl:Enabled=true`: diff --git a/docs/security.md b/docs/security.md index d1c0c1ee..c9b26f35 100644 --- a/docs/security.md +++ b/docs/security.md @@ -113,10 +113,22 @@ The Server accepts three OPC UA identity-token types: | Token | Handler | Notes | |---|---|---| | Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. | -| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. | +| UserName/Password | `ApiKeyOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by an `IApiKeyStore`. | The username is the literal `apikey` and the password is an `otua__` credential (#520). One indexed lookup plus one peppered HMAC-SHA256, no network I/O. The key resolves to a **principal**, whose `NodeAcl` grants become roles on a `RoleCarryingUserIdentity`. **LDAP is not on this path** — it backs AdminUI login only. | | X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. | -When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`. +When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; +the Host wires the real `ApiKeyOpcUaUserAuthenticator` as a singleton in `Program.cs`. Which +**`IApiKeyStore`** backs it depends on the node's role: + +| Node | Store | Revocation takes effect | +|---|---|---| +| `admin` (including fused `admin,driver`) | `ConfigDbApiKeyStore` — reads the config database per request | On the **next request** | +| `driver`-only | `ArtifactApiKeyStore` — reads the keys carried by the deployment artifact, held in memory | On the **next deployment** | +| neither role | `NullOpcUaUserAuthenticator` — denies every UserName token | n/a | + +The deny-all is registered **explicitly**, not left absent: `OtOpcUaServerHostedService` takes +`IOpcUaUserAuthenticator` as a required constructor parameter, so an unregistered seam fails DI and the +node does not boot at all. ### LDAP bind flow (`OtOpcUaLdapAuthService`) @@ -311,11 +323,17 @@ What is real about it: validated, versioned and persisted. - `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL edit shifts the artifact's `RevisionHash` and ships to every node. -- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive - and are dropped. +- The node side **does** deserialize them as of #520 stage 4 — + `DeploymentArtifact.ParseDataPlaneAuthority` reads the artifact's `NodeAcls` (scoped to the node's own + cluster) and `ApiKeys`, and `OpcUaPublishActor` installs both on the node's + `IDataPlaneAuthoritySource` on every apply. The node manager holds that source, so it finally has a + cluster identity. +- **Nothing evaluates them yet.** The grants are decoded and available; the enforcement seams that would + consult them land in stages 5 and 6. -So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour -to expect until the tracking issue below is closed. +So an operator can still author a grant, deploy it green, and have it change nothing at the node — but +the grant now reaches the node rather than being dropped at its door. That is the behaviour to expect +until the tracking issue below is closed. **The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"): @@ -323,14 +341,18 @@ to expect until the tracking issue below is closed. `Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot express — yet the write gate is attached to raw tags. Roughly half the writable address space has no scope to evaluate. This is a design decision, not wiring. -2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the - session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list - is discarded inside `LdapOpcUaUserAuthenticator`. +2. ~~**The identity carries roles, not groups.**~~ **Dissolved by #520 stage 2.** The column is now + `NodeAcl.Principal` and the principal comes from the presented API key (or the reserved `anonymous`), + not from a directory — so there is no group list to lose. 3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have to be derived from the artifact's UNS relations. 4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but - nothing in production constructs one, stamps a generation, or drives a refresh. + nothing in production constructs one per session. **Half-closed by stage 4:** the *generation* now + exists and advances — `DataPlaneAuthorityState` stamps a monotonic `GenerationId` each time an + artifact with a new `RevisionHash` is applied — so there is something to stamp a session with. Doing + the stamping is stage 5's job, because until an enforcement seam reads it, a per-session state object + would be constructed and never consulted. Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead` diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs new file mode 100644 index 00000000..2d199ea6 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/DataPlaneAuthority.cs @@ -0,0 +1,235 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// +/// The data-plane authorization inputs a node is currently serving: the API keys that may +/// authenticate, the ACL grants naming their principals, the cluster the node belongs to, and the +/// generation those were resolved at (#520, stage 4). +/// +/// +/// +/// Immutable. A new deployment produces a whole new snapshot which is swapped in atomically, so +/// a request that started against the previous generation finishes against a coherent set +/// rather than a half-updated one. +/// +/// +/// The lookups are precomputed because the consumer is OPC UA session activation, which the SDK +/// raises synchronously under a SessionManager-wide lock — every session in the +/// server serializes behind it. A dictionary probe is the whole cost. +/// +/// +public sealed class DataPlaneAuthoritySnapshot +{ + /// The state of a node that has not yet applied an artifact. Authenticates nothing. + public static readonly DataPlaneAuthoritySnapshot Empty = Create(null, string.Empty, 0, [], []); + + private DataPlaneAuthoritySnapshot( + string? clusterId, + string revisionHash, + long generationId, + IReadOnlyList keys, + IReadOnlyList grants, + IReadOnlyDictionary keysById, + IReadOnlyDictionary> grantsByPrincipal) + { + ClusterId = clusterId; + RevisionHash = revisionHash; + GenerationId = generationId; + Keys = keys; + Grants = grants; + _keysById = keysById; + _grantsByPrincipal = grantsByPrincipal; + } + + private readonly IReadOnlyDictionary _keysById; + private readonly IReadOnlyDictionary> _grantsByPrincipal; + + /// + /// The cluster this node belongs to per the artifact's ClusterNode row, or null on a + /// single-cluster / legacy artifact where no scoping applies. + /// + public string? ClusterId { get; } + + /// SHA-256 of the artifact these inputs came from; empty before the first apply. + public string RevisionHash { get; } + + /// + /// Monotonic, node-local generation stamp. Zero means "nothing applied yet"; it increases by one + /// each time an artifact with a different is applied. + /// + public long GenerationId { get; } + + /// Every key in the artifact, revoked and expired ones included — filtering is the lookup's job. + public IReadOnlyList Keys { get; } + + /// Every ACL grant scoped to this node's cluster. + public IReadOnlyList Grants { get; } + + /// True once a real artifact has been applied. + public bool HasArtifact => GenerationId > 0; + + /// + /// Finds a key that is enabled and unexpired at . A revoked or expired + /// key returns null rather than a "found but unusable" result, mirroring + /// ConfigDbApiKeyStore, which filters both in SQL for the same reason: a key that must + /// not authenticate should never reach a caller that might treat a non-null return as a match. + /// + /// The public key id parsed from the presented credential. + /// Current UTC time, for the expiry check. + /// The usable key, or null. + public ApiKey? FindUsableKey(string apiKeyId, DateTime utcNow) + { + if (string.IsNullOrEmpty(apiKeyId)) return null; + if (!_keysById.TryGetValue(apiKeyId, out var key)) return null; + if (!key.Enabled) return null; + if (key.ExpiresUtc is { } expires && expires <= utcNow) return null; + return key; + } + + /// Returns the grants naming a principal; empty when none. + /// The principal to look up. + /// The matching grants. + public IReadOnlyList GrantsFor(string principal) => + !string.IsNullOrEmpty(principal) && _grantsByPrincipal.TryGetValue(principal, out var g) + ? g + : []; + + /// Builds a snapshot, precomputing the key and grant lookups. + /// The node's cluster, or null when the artifact is single-cluster. + /// SHA-256 of the source artifact. + /// The node-local generation stamp. + /// The API keys carried by the artifact. + /// The ACL grants for this node's cluster. + /// The built snapshot. + public static DataPlaneAuthoritySnapshot Create( + string? clusterId, + string revisionHash, + long generationId, + IReadOnlyList keys, + IReadOnlyList grants) + { + ArgumentNullException.ThrowIfNull(revisionHash); + ArgumentNullException.ThrowIfNull(keys); + ArgumentNullException.ThrowIfNull(grants); + + // OrdinalIgnoreCase throughout: key ids are minted as lowercase hex, but a client hand-copying a + // key out of the AdminUI can upper-case it, and the principal comparison must agree with the + // evaluator's (TriePermissionEvaluator matches principals OrdinalIgnoreCase). + var byId = new Dictionary(keys.Count, StringComparer.OrdinalIgnoreCase); + foreach (var key in keys) + { + if (string.IsNullOrWhiteSpace(key.ApiKeyId)) continue; + // A duplicate id cannot come from the database (UX_ApiKey_ApiKeyId is unique), so this only + // fires on a corrupt artifact. Keep the FIRST and drop the rest rather than throwing: the + // alternative is a node that refuses to authenticate anything because one row is malformed. + byId.TryAdd(key.ApiKeyId, key); + } + + var byPrincipal = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var grant in grants) + { + if (string.IsNullOrWhiteSpace(grant.Principal)) continue; + if (!byPrincipal.TryGetValue(grant.Principal, out var list)) + byPrincipal[grant.Principal] = list = []; + list.Add(grant); + } + + var frozen = byPrincipal.ToDictionary( + kv => kv.Key, + kv => (IReadOnlyList)kv.Value, + StringComparer.OrdinalIgnoreCase); + + return new DataPlaneAuthoritySnapshot(clusterId, revisionHash, generationId, keys, grants, byId, frozen); + } +} + +/// Read side of the node's current data-plane authorization inputs. +public interface IDataPlaneAuthoritySource +{ + /// The snapshot in force right now. Never null; before the first apply. + DataPlaneAuthoritySnapshot Current { get; } +} + +/// +/// The source bound when nothing has been wired: permanently empty, so it authenticates no key and +/// grants no permission. The safe default for a node that is not serving a data plane. +/// +public sealed class EmptyDataPlaneAuthoritySource : IDataPlaneAuthoritySource +{ + /// The shared instance. + public static readonly EmptyDataPlaneAuthoritySource Instance = new(); + + private EmptyDataPlaneAuthoritySource() { } + + /// + public DataPlaneAuthoritySnapshot Current => DataPlaneAuthoritySnapshot.Empty; +} + +/// +/// Write side, fed once per deployment from the node's artifact-apply path. +/// +public interface IDataPlaneAuthoritySink +{ + /// + /// Installs the authorization inputs carried by a newly applied artifact. Idempotent: re-applying + /// the same leaves the generation stamp untouched. + /// + /// The node's cluster per the artifact, or null when single-cluster. + /// SHA-256 of the artifact bytes. + /// The API keys the artifact carries. + /// The ACL grants for this node's cluster. + /// The installed snapshot. + DataPlaneAuthoritySnapshot Apply( + string? clusterId, + string revisionHash, + IReadOnlyList keys, + IReadOnlyList grants); +} + +/// +/// Process-singleton holder of . Registered on every +/// driver-role node; the artifact-apply path writes it and OPC UA session activation reads it. +/// +public sealed class DataPlaneAuthorityState : IDataPlaneAuthoritySource, IDataPlaneAuthoritySink +{ + private readonly Lock _gate = new(); + private DataPlaneAuthoritySnapshot _current = DataPlaneAuthoritySnapshot.Empty; + + /// + public DataPlaneAuthoritySnapshot Current => Volatile.Read(ref _current); + + /// + public DataPlaneAuthoritySnapshot Apply( + string? clusterId, + string revisionHash, + IReadOnlyList keys, + IReadOnlyList grants) + { + ArgumentNullException.ThrowIfNull(revisionHash); + + lock (_gate) + { + var previous = _current; + + // The generation is a node-local counter driven BY the revision hash, not derived FROM it. + // Deriving a long from hash bytes was the obvious reading of "stamp the generation from the + // RevisionHash", and it is wrong: PermissionTrieCache treats the highest GenerationId as the + // current one (ClusterEntry.WithAdditional) and Prune keeps the highest. Hash fragments are + // unordered, so roughly half of all deploys would install a LOWER generation and the cache + // would keep serving the previous deployment's ACLs — a silent stale-authorization bug with + // no error anywhere. A counter is monotonic by construction. + // + // It resets to zero on restart, which is safe: sessions do not survive a restart and the trie + // cache is in-process, so there is no older generation left to be out-ordered by. + var generation = string.Equals(previous.RevisionHash, revisionHash, StringComparison.Ordinal) + && previous.HasArtifact + ? previous.GenerationId + : previous.GenerationId + 1; + + var next = DataPlaneAuthoritySnapshot.Create(clusterId, revisionHash, generation, keys, grants); + Volatile.Write(ref _current, next); + return next; + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs index 3c3e501b..15905821 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs @@ -13,10 +13,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; /// deployment artifact (driver-only nodes have no ConfigDb since Phase 4), so the only /// thing that can change a session's grants is a new deploy — which already bumps /// . The generation stamp, not the freshness timer, is the -/// real invalidation signal. Revisit whether -/// / earn their keep -/// when the session lifecycle lands (design doc stage 4); they are retained unchanged here -/// so this rename stays behaviour-neutral. +/// real invalidation signal. Stage 4 built that signal — +/// advances a monotonic generation on every artifact carrying a new RevisionHash — but +/// deliberately did NOT construct this type per session: nothing reads a +/// until the stage 5 enforcement seams exist, and a +/// per-session object that nothing consults is dormant wiring. So the question of whether +/// / earn their keep moves +/// to stage 5, where sessions are actually stamped. /// /// The membership is bounded by /// (default 5 min). After that, the next hot-path authz call re-resolves the principal diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs index 261b2ea7..4c1f22fc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs @@ -49,6 +49,13 @@ public static class ConfigComposer UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct), UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct), NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct), + // #520 stage 4: the data-plane credentials travel with the config, because a driver-only node + // has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every + // UserName token. Only the peppered HMAC of each secret ships — never the secret — and the + // pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob + // still cannot be turned into a working credential. This is also why revoking a key reaches a + // driver-only node only on the next deployment; see the ApiKey entity's remarks. + ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct), Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct), VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct), ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs new file mode 100644 index 00000000..477bda4f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ArtifactApiKeyStore.cs @@ -0,0 +1,80 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +// Fully-qualified rather than relative: this file's own namespace root contains a `Configuration` +// namespace (ZB.MOM.WW.OtOpcUa.Host.Configuration), so a relative `Configuration.Enums` binds there and +// fails to compile. +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; + +/// +/// served from the deployment artifact (#520, stage 4). This is the key +/// source for a driver-only node, which has had no ConfigDb since per-cluster mesh Phase 4 and +/// until now denied every UserName token. +/// +/// +/// +/// Purely in-memory: decodes the keys and grants once per applied +/// artifact and installs them on . That matters because the +/// SDK raises the impersonation callback synchronously, under a SessionManager-wide +/// lock — an I/O-bound lookup here would serialize every session activation in the server +/// behind one disk or network round trip. +/// +/// +/// ⚠️ Consequence: revocation lands on the next deployment, not immediately. A node reading +/// the ConfigDb sees Enabled = 0 on the next request; this one sees it only once a new +/// artifact arrives. That asymmetry is inherent to a node with no config database and is +/// documented for operators on the AdminUI key page. +/// +/// +/// Before the first artifact is applied the snapshot is empty, so every key is denied. That is the +/// safe direction, and it is also honest — a node that has not been deployed to has no configured +/// keys. +/// +/// +public sealed class ArtifactApiKeyStore : IApiKeyStore +{ + private readonly IDataPlaneAuthoritySource _authority; + + /// Initializes a new instance of the class. + /// The node's current data-plane authorization inputs. + public ArtifactApiKeyStore(IDataPlaneAuthoritySource authority) + { + ArgumentNullException.ThrowIfNull(authority); + _authority = authority; + } + + /// + public Task FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct) + { + // Read the snapshot ONCE. Re-reading Current for the grants would let a deploy land between the + // two reads and pair a key from one generation with grants from another. + var snapshot = _authority.Current; + var key = snapshot.FindUsableKey(apiKeyId, utcNow); + return Task.FromResult(key is null + ? null + : new ApiKeyLookup(key, ClusterGrants(snapshot, key.Principal))); + } + + /// + public Task> FindGrantsAsync(string principal, CancellationToken ct) => + Task.FromResult(ClusterGrants(_authority.Current, principal)); + + /// + /// Narrows a principal's grants to the Cluster-scoped ones, matching ConfigDbApiKeyStore. + /// Only those confer an interim role (see ); stage 5 widens + /// both stores to the full grant set once scope is actually enforced. + /// + private static IReadOnlyList ClusterGrants(DataPlaneAuthoritySnapshot snapshot, string principal) + { + var all = snapshot.GrantsFor(principal); + if (all.Count == 0) return []; + + var scoped = new List(all.Count); + foreach (var grant in all) + { + if (grant.ScopeKind == NodeAclScopeKind.Cluster) scoped.Add(grant); + } + return scoped; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs index c1f83f14..0c55c575 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs @@ -10,12 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; /// that has a ConfigDb — i.e. one carrying the admin role, including a fused admin+driver node. ///
/// -/// ⚠️ A driver-only node has no ConfigDb since per-cluster mesh Phase 4, so this store cannot -/// be registered there and such a node has no key source until the artifact-backed store lands in -/// stage 4. Until then a driver-only node authenticates no UserName tokens at all — it falls to -/// , which denies every attempt. That is -/// the safe direction, and anonymous sessions (still permitted, and still unrestricted until -/// enforcement lands in stages 5–6) are unaffected. +/// A driver-only node has no ConfigDb since per-cluster mesh Phase 4, so this store cannot be +/// registered there; that node uses instead (#520 stage 4). +/// +/// Reading per request is what makes revocation immediate here, which is why this store is +/// preferred wherever a ConfigDb exists — including on a fused admin+driver node. +/// /// public sealed class ConfigDbApiKeyStore : IApiKeyStore { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs index 6a01089b..6a13c448 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs @@ -7,9 +7,13 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; ///
/// /// A seam rather than a direct ConfigDb read because the two sources differ by node role. A fused -/// admin+driver node reads the config database directly; a driver-only node has no ConfigDb -/// at all since per-cluster mesh Phase 4, so its keys must come out of the deployment artifact. -/// Stage 2 ships the ConfigDb implementation; the artifact-backed one lands in stage 4. +/// admin+driver node reads the config database directly (); a +/// driver-only node has no ConfigDb at all since per-cluster mesh Phase 4, so its keys come out +/// of the deployment artifact (). +/// +/// The two differ in one operator-visible way: the ConfigDb store sees a revocation on the next +/// request, the artifact store only once a new deployment reaches the node. +/// /// public interface IApiKeyStore { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index fe582630..19b9cb58 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Runtime; @@ -34,6 +35,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl private readonly DeferredServiceLevelPublisher _deferredServiceLevel; private readonly IOpcUaUserAuthenticator _userAuthenticator; private readonly IHistorianDataSource _historianDataSource; + private readonly IDataPlaneAuthoritySource _dataPlaneAuthority; private readonly ServerHistorianOptions _serverHistorianOptions; private readonly Func _actorSystemAccessor; private readonly ActorRegistry _actorRegistry; @@ -55,6 +57,11 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl /// node, the same source the address-space sink + node-write gateway come from), or the configured /// HistorianGateway read client when AddServerHistorian enabled it. Wired onto the node manager in /// . + /// #520 stage 4 — the node's data-plane authorization inputs (its + /// cluster, the current auth generation, and the ACL grants), refreshed from every applied deployment + /// artifact. Wired onto the node manager in so the stage 5/6 enforcement seams + /// have a cluster identity to build a NodeScope with. Resolves to the empty source on a node with + /// no driver role. /// App configuration; the ServerHistorian section is bound here to /// read for the node manager. Bound directly /// (not via IOptions) so the wiring is self-contained in this hosted service — the option's @@ -73,6 +80,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl DeferredServiceLevelPublisher deferredServiceLevel, IOpcUaUserAuthenticator userAuthenticator, IHistorianDataSource historianDataSource, + IDataPlaneAuthoritySource dataPlaneAuthority, IConfiguration configuration, Func actorSystemAccessor, ActorRegistry actorRegistry, @@ -83,6 +91,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _deferredServiceLevel = deferredServiceLevel; _userAuthenticator = userAuthenticator; _historianDataSource = historianDataSource; + _dataPlaneAuthority = dataPlaneAuthority; // Bind the ServerHistorian section directly (defaults survive when it's absent) — we only need // MaxTieClusterOverfetch here, and binding it ourselves keeps the wiring in this one file. _serverHistorianOptions = @@ -216,6 +225,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl // The node manager's HistoryRead overrides block-bridge to whatever source is set here. _server.SetHistorianDataSource(_historianDataSource); + // #520 stage 4 — hand the node manager the node's data-plane authorization inputs. Nothing reads + // them until the stage 5/6 enforcement seams land, so this changes no behaviour today; it exists + // because the node manager had NO cluster identity, and NodeScope.ClusterId is required while the + // evaluator fails closed on a mismatch — every scope it could have built would have been denied. + _server.SetDataPlaneAuthority(_dataPlaneAuthority); + // Propagate the configured tie-cluster over-fetch bound onto the node manager so HistoryRead-Raw // can page WITHIN an oversized tie cluster (more samples sharing one SourceTimestamp than the page // cap) rather than failing the read. The option's default (65536) survives when the ServerHistorian @@ -257,6 +272,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _server?.SetNativeAlarmAckRouter(null); // Restore the Null historian so a late HistoryRead doesn't hit a disposed read client. _server?.SetHistorianDataSource(null); + // Restore the empty authority so a late session activation cannot authorize against a stopping node. + _server?.SetDataPlaneAuthority(null); return Task.CompletedTask; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index ef6c3c62..f098d719 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -351,12 +351,31 @@ if (hasDriver) // (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP // registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP. // Only IOpcUaUserAuthenticator moves. + // #520 stage 4 — the node's data-plane authorization inputs (keys + ACL grants + cluster + auth + // generation), refreshed by OpcUaPublishActor from every applied artifact. Registered on any + // driver-role node because that is what serves an OPC UA data plane; the two interfaces resolve to + // the SAME instance, since the writer and the readers must see one snapshot. + if (hasDriver) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + } + else + { + // The SOURCE half must always resolve — OtOpcUaServerHostedService takes it as a required ctor + // parameter, and the built-in DI container does not fall back to a default value for a parameter + // whose service is unregistered; it throws and the node fails to start. Same trap, and the same + // fix, as the NullOpcUaUserAuthenticator registration below. Only the SINK is genuinely optional + // (nothing publishes artifacts on a node with no driver role). + builder.Services.AddSingleton(EmptyDataPlaneAuthoritySource.Instance); + } + if (hasAdmin) { - // ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin. - // A driver-only node therefore has no key source until the artifact-backed store lands in - // stage 4; it falls through to NullOpcUaUserAuthenticator, which denies every UserName token. - // Safe direction, and anonymous sessions are unaffected. + // A node WITH a ConfigDb reads keys straight from it, which is what makes revocation take effect + // on the very next request rather than on the next deployment. Preferred wherever it is available, + // including on a fused admin+driver node. builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -365,11 +384,19 @@ if (hasDriver) sp.GetRequiredService>(), sp.GetRequiredService>().Value.Pepper)); } + else if (hasDriver) + { + // Phase 4 left a driver-only node with no ConfigDb, so its keys ride the deployment artifact. + // Before stage 4 this node fell through to NullOpcUaUserAuthenticator and denied EVERY UserName + // token — safe, but it meant no machine client could authenticate against a site node at all. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } else { - // MUST be registered explicitly, not left absent: OtOpcUaServerHostedService takes - // IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so an unregistered seam is not a - // quiet fallback to deny-all — it fails DI and the node does not start at all. + // Neither role: nothing serves a data plane here. MUST be registered explicitly, not left absent — + // OtOpcUaServerHostedService takes IOpcUaUserAuthenticator as a REQUIRED constructor parameter, so + // an unregistered seam is not a quiet fallback to deny-all; it fails DI and the node does not start. builder.Services.AddSingleton(NullOpcUaUserAuthenticator.Instance); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index c9c1b7c2..9b4fb06b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -3,6 +3,7 @@ using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; // The SDK's HistoryRead service result (the value the override fills + hands back) and the historian // data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous: @@ -243,6 +244,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 set => _historianDataSource = value ?? NullHistorianDataSource.Instance; } + /// + /// #520 stage 4 — the node's current data-plane authorization inputs: which cluster it serves, + /// the generation those were resolved at, and the ACL grants themselves. + /// + /// + /// + /// This closes the fourth #520 blocker: NodeScope.ClusterId is required and + /// TriePermissionEvaluator fails closed when it disagrees with the session's, but the + /// node manager had no idea what cluster it was serving — so no NodeScope it built + /// could ever have evaluated. It is fed here rather than at construction because the SDK + /// constructs this manager during StartAsync, before any artifact has been applied, + /// and the value changes on every deploy. + /// + /// + /// Stage 4 only supplies it. The enforcement seams that consume it — the write gate, the alarm + /// gates, HistoryRead, and (stage 6) read/browse/subscribe — land in stages 5 and 6, so + /// nothing reads this yet and authorization behaviour is unchanged. + /// + /// + /// Backed by a volatile field for the same reason as : + /// written on the deploy path, read on SDK request threads. Assigning null restores the + /// empty source, which authenticates and authorizes nothing. + /// + /// + public IDataPlaneAuthoritySource DataPlaneAuthority + { + get => _dataPlaneAuthority; + set => _dataPlaneAuthority = value ?? EmptyDataPlaneAuthoritySource.Instance; + } + + private volatile IDataPlaneAuthoritySource _dataPlaneAuthority = EmptyDataPlaneAuthoritySource.Instance; + /// /// The upper bound on the bounded over-fetch uses to page WITHIN an /// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index 5c464248..8bad65bd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -2,6 +2,7 @@ using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -103,6 +104,28 @@ public sealed class OtOpcUaSdkServer : StandardServer return true; } + /// + /// Wire the node's data-plane authorization inputs (#520 stage 4) onto the created + /// — the cluster it serves, the current auth generation, and the + /// ACL grants. The host calls this after start with the DI-resolved + /// , which the artifact-apply path refreshes on every + /// deploy. Passing null restores the empty source (authorizes nothing). No-op (returns + /// false) when the node manager has not been created yet, so the caller can detect a + /// too-early call (mirrors ). + /// + /// The node's data-plane authorization inputs; may be null to restore the + /// empty default. + /// true when the source was set on a live node manager; false when no node + /// manager exists yet. + public bool SetDataPlaneAuthority(IDataPlaneAuthoritySource? source) + { + if (_otOpcUaNodeManager is null) return false; + // The DataPlaneAuthority setter null-coalesces to the empty source, so a null is intentional; + // forgive the nullable-in here. + _otOpcUaNodeManager.DataPlaneAuthority = source!; + return true; + } + /// protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index d79d0b08..a385b468 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -37,6 +37,24 @@ public enum ClusterFilterMode Suppress, } +/// +/// The data-plane authorization inputs decoded out of a deployment artifact for one node (#520). +/// +/// The node's cluster per its ClusterNode row, or null on a +/// single-cluster / legacy artifact where no scoping applies. +/// Every ApiKey the artifact carries, revoked and expired included — the +/// lookup filters those, so that a revocation is visible rather than indistinguishable from a +/// missing key. +/// The NodeAcl rows scoped to . +public sealed record DataPlaneAuthorityProjection( + string? ClusterId, + IReadOnlyList Keys, + IReadOnlyList Grants) +{ + /// The projection of an absent or unparseable artifact: no cluster, no keys, no grants. + public static readonly DataPlaneAuthorityProjection Empty = new(null, [], []); +} + /// Resolved scoping decision for a node against an artifact. /// /// None = apply everything (single-cluster / legacy); ScopeTo = filter to ; @@ -534,6 +552,170 @@ public static class DeploymentArtifact : all; } + /// + /// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions: + /// the ApiKeys the artifact carries and the NodeAcls granting their principals + /// permissions (#520, stage 4). + /// + /// + /// + /// This is the ONLY key source a driver-only node has — per-cluster mesh Phase 4 left + /// it with no ConfigDb, so without this it denies every UserName token. + /// + /// + /// Grants are scoped to the node's own cluster (); keys are + /// not, because ApiKey carries no ClusterId — a key is a fleet-wide credential and it + /// is the ACL rows that bind it to a cluster's nodes. A + /// scope — the node's row is absent from a multi-cluster artifact — yields NO grants, matching + /// the driver-spawn path's reading that an unlisted node was not deployed to. + /// + /// + /// Lenient like every other parser here: an empty or malformed blob yields empty lists. The + /// caller must NOT treat that as "no keys are valid" and install it — see issue #485; the + /// apply paths only reach this with bytes that already passed their own emptiness guard. + /// + /// + /// The deployment artifact blob to parse. + /// The node's identity, used to resolve cluster scope for the grants. + /// The node's cluster (null when single-cluster), its keys, and its cluster's grants. + public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan blob, string nodeId) + { + if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty; + + var scope = ResolveClusterScope(blob, nodeId); + + try + { + using var doc = JsonDocument.Parse(blob.ToArray()); + var root = doc.RootElement; + + var keys = new List(); + foreach (var el in EnumerateArray(root, "ApiKeys")) + { + var key = TryReadApiKey(el); + if (key is not null) keys.Add(key); + } + + var grants = new List(); + foreach (var el in EnumerateArray(root, "NodeAcls")) + { + var acl = TryReadNodeAcl(el); + if (acl is null) continue; + if (scope.Mode == ClusterFilterMode.Suppress) continue; + if (scope.Mode == ClusterFilterMode.ScopeTo + && !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + grants.Add(acl); + } + + return new DataPlaneAuthorityProjection(scope.ClusterId, keys, grants); + } + catch (JsonException) + { + return DataPlaneAuthorityProjection.Empty; + } + } + + /// Reads one ApiKeys element; null when a field the credential check depends on is + /// missing, so a half-written row can never authenticate. + private static ApiKey? TryReadApiKey(JsonElement el) + { + var apiKeyId = ReadString(el, "ApiKeyId"); + var secretHash = ReadString(el, "SecretHash"); + var principal = ReadString(el, "Principal"); + if (string.IsNullOrWhiteSpace(apiKeyId) + || string.IsNullOrWhiteSpace(secretHash) + || string.IsNullOrWhiteSpace(principal)) + { + return null; + } + + return new ApiKey + { + ApiKeyId = apiKeyId, + Name = ReadString(el, "Name") ?? apiKeyId, + SecretHash = secretHash, + Principal = principal, + // A row whose Enabled flag did not survive serialisation is treated as REVOKED. The other + // direction would resurrect a revoked key out of a malformed artifact. + Enabled = ReadBool(el, "Enabled") ?? false, + CreatedUtc = ReadDateTime(el, "CreatedUtc") ?? default, + ExpiresUtc = ReadDateTime(el, "ExpiresUtc"), + Notes = ReadNullableString(el, "Notes"), + }; + } + + /// Reads one NodeAcls element; null when identity, cluster or scope kind is unreadable. + private static NodeAcl? TryReadNodeAcl(JsonElement el) + { + var nodeAclId = ReadString(el, "NodeAclId"); + var clusterId = ReadString(el, "ClusterId"); + var principal = ReadString(el, "Principal"); + if (string.IsNullOrWhiteSpace(nodeAclId) + || string.IsNullOrWhiteSpace(clusterId) + || string.IsNullOrWhiteSpace(principal)) + { + return null; + } + + // ConfigComposer serialises enums as NUMBERS (its JsonSerializerOptions carries no + // JsonStringEnumConverter), so these read as int. Keep the two sides in step — a string-enum + // converter added to the composer would silently drop every grant here. + var scopeKind = ReadInt32(el, "ScopeKind"); + if (scopeKind is null || !Enum.IsDefined(typeof(NodeAclScopeKind), scopeKind.Value)) return null; + + return new NodeAcl + { + NodeAclId = nodeAclId, + ClusterId = clusterId, + Principal = principal, + ScopeKind = (NodeAclScopeKind)scopeKind.Value, + ScopeId = ReadNullableString(el, "ScopeId"), + // An unreadable bitmask grants nothing rather than defaulting to something permissive. + PermissionFlags = (NodePermissions)(ReadInt32(el, "PermissionFlags") ?? 0), + Notes = ReadNullableString(el, "Notes"), + }; + } + + /// Reads a boolean property; null when absent or not a JSON boolean. + private static bool? ReadBool(JsonElement el, string property) => + el.TryGetProperty(property, out var p) + ? p.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + } + : null; + + /// Reads an int property; null when absent, not a number, or out of int range. + private static int? ReadInt32(JsonElement el, string property) => + el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.Number + && p.TryGetInt32(out var v) + ? v + : null; + + /// Reads a UTC DateTime property; null when absent, JSON null, or unparseable. + private static DateTime? ReadDateTime(JsonElement el, string property) + { + if (!el.TryGetProperty(property, out var p) || p.ValueKind != JsonValueKind.String) return null; + if (!p.TryGetDateTime(out var v)) return null; + + // Normalise to UTC, because every consumer compares against DateTime.UtcNow and an expiry that + // silently shifted by the node's timezone is a key that outlives (or predeceases) its expiry. + // Unspecified must be SpecifyKind, NOT ToUniversalTime: EF materialises datetime2 as Unspecified, + // so the composer writes these UTC instants without an offset and ToUniversalTime would subtract + // the node's local offset from a value that was already UTC. + return v.Kind switch + { + DateTimeKind.Utc => v, + DateTimeKind.Local => v.ToUniversalTime(), + _ => DateTime.SpecifyKind(v, DateTimeKind.Utc), + }; + } + /// Reads a string property, tolerating a wrong JSON type: a property that is present but /// NOT a JSON string (number / bool / object / array) yields null rather than throwing /// from GetString(). Keeps the artifact-decode lenient diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index e2e0e174..8053d95a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Health; @@ -95,6 +96,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private readonly NodeId? _localNode; private readonly IDbContextFactory? _dbFactory; private readonly AddressSpaceApplier? _applier; + /// #520 stage 4 — receives the API keys + ACL grants carried by each applied artifact, so + /// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node + /// that is not serving an OPC UA data plane). + private readonly IDataPlaneAuthoritySink? _dataPlaneAuthority; private readonly IActorRef? _dbHealthProbe; /// Per-cluster mesh Phase 4: this is a driver-only node with NO ConfigDb (LocalDb is its /// config store). There is no DB to probe, so the ServiceLevel DB-reachability input is retired — @@ -166,7 +171,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? staleWindow = null, TimeSpan? probeFreshnessWindow = null, TimeSpan? healthTickInterval = null, - bool dbLess = false) => + bool dbLess = false, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) => Akka.Actor.Props.Create(() => new OpcUaPublishActor( sink ?? NullOpcUaAddressSpaceSink.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance, @@ -178,7 +184,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers staleWindow, probeFreshnessWindow, healthTickInterval, - dbLess)).WithDispatcher(DispatcherId); + dbLess, + dataPlaneAuthority)).WithDispatcher(DispatcherId); /// Test-only Props that omits the pinned-dispatcher requirement and skips the /// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster. @@ -214,7 +221,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? staleWindow = null, TimeSpan? probeFreshnessWindow = null, TimeSpan? healthTickInterval = null, - bool dbLess = false) => + bool dbLess = false, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) => Akka.Actor.Props.Create(() => new OpcUaPublishActor( sink ?? NullOpcUaAddressSpaceSink.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance, @@ -226,7 +234,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers staleWindow, probeFreshnessWindow, healthTickInterval, - dbLess)); + dbLess, + dataPlaneAuthority)); /// Initializes a new instance of the class. /// The OPC UA address space sink. @@ -259,9 +268,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? staleWindow = null, TimeSpan? probeFreshnessWindow = null, TimeSpan? healthTickInterval = null, - bool dbLess = false) + bool dbLess = false, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) { _sink = sink; + _dataPlaneAuthority = dataPlaneAuthority; _serviceLevel = serviceLevel; _subscribeRedundancyTopic = subscribeRedundancyTopic; _localNode = localNode; @@ -412,6 +423,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return; } + // #520 stage 4 — install this artifact's API keys + ACL grants BEFORE the address-space work + // below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a + // key or edits a grant without touching a single node produces an EMPTY address-space plan, so + // feeding this after that return would leave a revoked key authenticating until some unrelated + // structural change happened to redeploy. Authorization and topology change on their own + // schedules. + FeedDataPlaneAuthority(artifact); + var composition = _localNode is { } ln ? DeploymentArtifact.ParseComposition(artifact, ln.Value, inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency)) @@ -491,6 +510,43 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } + /// + /// Decode the artifact's data-plane authorization inputs (API keys + this cluster's ACL grants) + /// and install them for OPC UA session activation (#520 stage 4). No-op when nothing consumes + /// them. + /// + /// + /// Wrapped so a decode failure can never break a deploy — the same discipline as the historised-ref + /// feed. The cost of that choice is explicit: the node keeps serving the PREVIOUS key set, which is + /// the safe direction for availability but means a revocation carried by an artifact this node + /// could not decode has not taken effect. It is logged at Error for exactly that reason. + /// + private void FeedDataPlaneAuthority(byte[] artifact) + { + if (_dataPlaneAuthority is null) return; + + try + { + var nodeId = _localNode?.Value ?? string.Empty; + var projection = DeploymentArtifact.ParseDataPlaneAuthority(artifact, nodeId); + // The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses, + // which is what lets a node recognise a re-applied revision without the hash riding the wire. + var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact)); + var snapshot = _dataPlaneAuthority.Apply( + projection.ClusterId, revision, projection.Keys, projection.Grants); + + _log.Info( + "OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants})", + snapshot.GenerationId, projection.ClusterId ?? "(single-cluster)", revision[..12], + projection.Keys.Count, projection.Grants.Count); + } + catch (Exception ex) + { + _log.Error(ex, + "OpcUaPublish: failed to install data-plane authority from the applied artifact — the node keeps the PREVIOUS keys and grants, so any revocation in this deployment has NOT taken effect"); + } + } + /// Read a specific deployment's artifact blob from ConfigDb (the one just applied, /// which may not be Sealed yet). Empty array on any failure — treats that as /// "no answer" and abandons the rebuild, leaving the served address space untouched (issue #485). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 793088f0..851a2b8c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; @@ -467,6 +468,11 @@ public static class ServiceCollectionExtensions loggerFactory.CreateLogger(), provisioning: provisioning, historizedSubscriptions: historizedSubscriptions); + // #520 stage 4 — the sink that receives each applied artifact's API keys + ACL grants, so OPC + // UA session activation can authenticate. Optional: GetService (not GetRequiredService), because + // a node with no driver role never registers it and test harnesses build this graph without it; + // the actor treats null as "nothing consumes authorization inputs" and skips the decode. + var dataPlaneAuthority = resolver.GetService(); var publishActor = system.ActorOf( OpcUaPublishActor.Props( sink: addressSpaceSink, @@ -475,7 +481,8 @@ public static class ServiceCollectionExtensions dbFactory: dbFactory, applier: applier, dbHealthProbe: dbHealth, - dbLess: dbFactory is null), + dbLess: dbFactory is null, + dataPlaneAuthority: dataPlaneAuthority), OpcUaPublishActorName); registry.Register(publishActor); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj index 92445c84..094b2e67 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj @@ -21,6 +21,10 @@ + +