diff --git a/CLAUDE.md b/CLAUDE.md index 39254018..9947f054 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,57 @@ 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**. +- **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. +- **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. 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/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}" 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..7c2e9c00 --- /dev/null +++ b/docs/plans/2026-07-31-apikey-acl-enforcement-design.md @@ -0,0 +1,192 @@ +# 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.** *(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.** ✅ **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. 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) ✅ **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`: + +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. diff --git a/docs/security.md b/docs/security.md index 5604c76d..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`) @@ -162,19 +174,90 @@ 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. + +### 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, +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. --- @@ -240,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"): @@ -252,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/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/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/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/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/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 a2823b38..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") @@ -709,11 +767,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 +778,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 +803,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..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 => @@ -449,7 +476,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 +484,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/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/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs new file mode 100644 index 00000000..47d122e4 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// +/// 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/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/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/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/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/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..15905821 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,28 @@ 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. 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 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 +44,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/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..8fc31a67 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor @@ -0,0 +1,209 @@ +@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/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/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.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/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/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..60fa56e0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOpcUaUserAuthenticator.cs @@ -0,0 +1,176 @@ +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; + +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); + } + + 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( + "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); + } + + /// + /// + /// 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/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 new file mode 100644 index 00000000..0c55c575 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ConfigDbApiKeyStore.cs @@ -0,0 +1,67 @@ +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; 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 +{ + 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; + + 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. + return await db.NodeAcls + .AsNoTracking() + .Where(a => a.Principal == principal && a.ScopeKind == NodeAclScopeKind.Cluster) + .ToListAsync(ct) + .ConfigureAwait(false); + } +} 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..6a13c448 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/IApiKeyStore.cs @@ -0,0 +1,43 @@ +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 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 +{ + /// + /// 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); + + /// + /// 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. +/// 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/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.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 51380bb8..f098d719 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 @@ -339,7 +346,59 @@ 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. + // #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) + { + // 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(); + + // 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 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 + { + // 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); + } // 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/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 7a62694e..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; @@ -51,6 +52,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 +252,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 @@ -246,7 +270,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. @@ -254,11 +280,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; } @@ -307,6 +340,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 @@ -348,7 +441,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); } @@ -440,13 +533,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/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.OpcUaServer/Security/IOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs index 1f136333..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; /// @@ -22,8 +24,32 @@ 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())); } + /// Outcome of a UserName authentication attempt. populates the session identity's role set. public sealed record OpcUaUserAuthResult( bool Success, 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/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 @@ + +