06c56d7719
Two independent defects surfaced in review of #520 stage 3.
#532 — Cluster:Roles had no OTOPCUA_ROLES fallback, despite two doc sites
promising one. A node configured only through the env var — which drives ALL of
Program.cs's conditional wiring, so it reads as the authoritative knob — bound an
empty Roles array and joined the cluster carrying no Akka roles at all. It ran,
served, and logged nothing wrong, while central reported "no driver-role nodes
are Up" and every deployment to it timed out. Not hypothetical: the field fix on
wonder-app-vd03 was to hand-add Cluster__Roles__0/1 to a Windows service's
registry environment. Anyone reading the code to diagnose that box would have
concluded from the XML doc that the fallback was already applied.
AddOtOpcUaCluster now post-configures AkkaClusterOptions through the shared
RoleParser.ResolveEffectiveRoles, so BuildClusterOptions (Akka's own member
roles), ClusterRoleInfo, the ControlPlane singleton scope and
ConfigDbApiKeyStore's ClusterId all see one answer. Two sibling validators
(ConfigSource, Telemetry) read Cluster:Roles directly and were exempting the
exact node they exist for — an env-only driver-only node has no ConfigDb and
could sit on ConfigSource:Mode=Direct unchallenged. They resolve the same way now.
Also collapsed the four open-coded `Roles.FirstOrDefault(IsClusterRole)` +
ClusterIdFromRole copies into RoleParser.ClusterIdFromRoles. They had drifted:
three classified the cluster role ordinally, so a verbatim-bound `Cluster-SITE-A`
yielded a null ClusterId — and a null ClusterId applies NO cluster filter, which
made #528's cross-cluster over-grant guard fail OPEN. IsClusterRole is now
case-insensitive; the ClusterId suffix keeps its authored spelling, because
normalizing it would silently rename the Akka role of a running deployment.
#533 — three string-identity rules in one authorization path. AclScopeRef used
default (ordinal) equality while the trie builder filtered ClusterId and hashed
principals OrdinalIgnoreCase; the deploy gate's cross-cluster check was ordinal
too, which was the worse half — it could reject a whole deploy over a ClusterId
case difference the runtime would have accepted. It only worked because resolver
and builder were handed the same NodeAcl instances.
The rule is now stated once, on AclScopeRef, and applied throughout:
identifiers (ScopeId, ClusterId, Principal) compare OrdinalIgnoreCase; path
segments compare Ordinal. AclScopePathResolver re-keys its own maps rather than
documenting a comparer contract for callers, so the two independent producers —
EF at the gate, artifact JSON on the node — cannot diverge from the outside.
PermissionTrieNode.Children stays Ordinal: those are browse names, and Speed /
speed are two nodes that can coexist under one device.
Split PermissionTrieBuildDiagnosticReason.RetiredScopeKind out of
UnresolvedScope. A legacy FolderSegment row and a typo'd id had the same message
and different remedies.
Falsified: five mutations, each reddening only its own guard — remove the
PostConfigure (3 tests), revert IsClusterRole to ordinal (3), revert AclScopeRef
equality (1), revert the resolver's map comparer (6), revert the gate's
comparisons (2), remove the FolderSegment short-circuit (1).
Test isolation: OTOPCUA_ROLES is process-global and xUnit runs classes in
parallel, so the three validator suites that now resolve effective roles share a
non-parallel collection. Without it, cases passing no roles would intermittently
observe another class's env var.
Full solution build clean; full suite green apart from two pre-existing failures
confirmed identical on 3a6b2a1d (PrimaryGateFailoverTests, and
DriverProbeRegistrationTests expecting 10 probe types where 12 now register)
plus the AbLegacy integration suite, which needs a docker fixture.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
648 lines
49 KiB
Markdown
648 lines
49 KiB
Markdown
# Security
|
|
|
|
> **v2 status (2026-05-26).** The four security concerns below are unchanged in v2.
|
|
> Paths + project names moved: `OtOpcUa.Server/Security/` → `OtOpcUa.Security/`
|
|
> (`Ldap/`, `Jwt/`, `Endpoints/AuthEndpoints.cs`), `OtOpcUa.Admin` is gone (its
|
|
> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies
|
|
> register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth`
|
|
> (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather
|
|
> than in a separate Admin process. The Admin UI uses a **single Cookie
|
|
> authentication scheme** — there is no `AddJwtBearer` pipeline. The
|
|
> `Security:Jwt` section configures `JwtTokenService`, which mints a JWT at the
|
|
> `/auth/token` endpoint for **external** consumers (OPC UA clients / automation
|
|
> scripts); the cookie itself stores the `ClaimsPrincipal` directly. DataProtection
|
|
> keys persist to the shared Config DB (`PersistKeysToDbContext<OtOpcUaConfigDbContext>`)
|
|
> so cookies survive failover between admin-role nodes.
|
|
>
|
|
> See `docs/plans/2026-05-26-akka-hosting-alignment-design.md` §5 for the v2
|
|
> auth + DataProtection rationale.
|
|
|
|
OtOpcUa has four independent security concerns. This document covers all four:
|
|
|
|
1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust).
|
|
2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind.
|
|
3. **Data-plane authorization** — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by **two coarse, server-wide LDAP role checks** (`WriteOperate`, `AlarmAck`) — **not** by the `NodeAcl` / `PermissionTrie` subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry **no** authorization check at all. See [Data-Plane Authorization](#data-plane-authorization).
|
|
4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`.
|
|
|
|
Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB.
|
|
|
|
---
|
|
|
|
## Transport Security
|
|
|
|
### Overview
|
|
|
|
The OtOpcUa Server supports configurable OPC UA transport security profiles that control how data is protected on the wire between OPC UA clients and the server.
|
|
|
|
There are two distinct layers of security in OPC UA:
|
|
|
|
- **Transport security** -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the `OpcUa:EnabledSecurityProfiles` setting controls.
|
|
- **UserName token encryption** -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. UserName authentication therefore works on `None` endpoints too — the credentials themselves are always encrypted. A secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.
|
|
|
|
### Supported security profiles
|
|
|
|
The profiles are the members of the `OpcUaSecurityProfile` enum (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`). The server ships **three** baseline profiles; the config value is the bare enum-member name (no hyphens, no underscores):
|
|
|
|
| Enum member | Security Policy | Message Security Mode | Description |
|
|
|---------------------------------|------------------|-----------------------|--------------------------------------------------|
|
|
| `None` | None | None | No signing or encryption. Suitable for development and isolated networks only. |
|
|
| `Basic256Sha256Sign` | Basic256Sha256 | Sign | Messages are signed but not encrypted. Protects against tampering but data is visible on the wire. |
|
|
| `Basic256Sha256SignAndEncrypt` | Basic256Sha256 | SignAndEncrypt | Messages are both signed and encrypted. Full protection against tampering and eavesdropping. |
|
|
|
|
`BuildSecurityPolicies` (`OpcUaApplicationHost.cs`) maps each configured profile to an SDK `ServerSecurityPolicy`. The server exposes a separate endpoint per configured profile and clients select the one they prefer at session open. The enum's XML doc notes that Aes128/Aes256 variants can be added later by extending the enum + `BuildSecurityPolicies` — the wiring is profile-agnostic — but they are **not implemented today**. There is no `SecurityProfileResolver` class.
|
|
|
|
> **Config value form.** The enum binds by member name, so a profile string with hyphens (e.g. `Basic256Sha256-Sign`) does **not** bind — use the exact enum-member spelling above. If `EnabledSecurityProfiles` is empty, the server falls back to a single `None` endpoint (logged, very visible) so it still has a listening endpoint.
|
|
|
|
### Configuration
|
|
|
|
Transport security is configured in the `OpcUa` section of the Host process's bootstrap `appsettings.json` (bound to `OpcUaApplicationHostOptions`):
|
|
|
|
```json
|
|
{
|
|
"OpcUa": {
|
|
"ApplicationName": "OtOpcUa",
|
|
"ApplicationUri": "urn:node-a:OtOpcUa",
|
|
"PublicHostname": "0.0.0.0",
|
|
"OpcUaPort": 4840,
|
|
"PkiStoreRoot": "C:/ProgramData/OtOpcUa/pki",
|
|
"AutoAcceptUntrustedClientCertificates": false,
|
|
"EnabledSecurityProfiles": [ "Basic256Sha256Sign", "Basic256Sha256SignAndEncrypt" ]
|
|
}
|
|
}
|
|
```
|
|
|
|
`EnabledSecurityProfiles` is a **list** — the server publishes one endpoint per entry. The default (when the key is omitted) is all three baseline profiles (`None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`); production deployments typically drop `None`. The list must contain at least one entry (`OpcUaApplicationHostOptionsValidator` enforces `MinCount(…, 1)`).
|
|
|
|
The server certificate is auto-generated on first start if none exists in `PkiStoreRoot/own/`. Always generated even for `None`-only deployments because UserName token encryption depends on it.
|
|
|
|
### PKI directory layout
|
|
|
|
```
|
|
{PkiStoreRoot}/
|
|
own/ Server's own application certificate and private key
|
|
issuer/ CA certificates that issued trusted client certificates
|
|
trusted/ Explicitly trusted client (peer) certificates
|
|
rejected/ Certificates that were presented but not trusted
|
|
```
|
|
|
|
### Certificate trust flow
|
|
|
|
When a client connects using a secure profile (`Sign` or `SignAndEncrypt`), the following trust evaluation occurs:
|
|
|
|
1. The client presents its application certificate during the secure channel handshake.
|
|
2. The server checks whether the certificate exists in the `trusted/` store.
|
|
3. If found, the connection proceeds.
|
|
4. If not found and `AutoAcceptUntrustedClientCertificates` is `true`, the certificate is automatically copied to `trusted/` and the connection proceeds.
|
|
5. If not found and `AutoAcceptUntrustedClientCertificates` is `false`, the certificate is copied to `rejected/` and the connection is refused.
|
|
|
|
The Admin UI `Certificates.razor` page (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor`) lists the contents of each PKI sub-store (own / trusted / issuer / rejected) by reading the `OpcUa:PkiStoreRoot` path from configuration. **FleetAdmin** users get per-row actions on the **trusted** and **rejected** stores: **Trust** (move a rejected cert into `trusted/`), **Untrust** (move a trusted cert back into `rejected/`), and **Delete** (remove a cert from either store). Each action requires an inline confirmation, is re-checked against the `FleetAdmin` policy server-side, and is honored live — the SDK trust list re-enumerates the directory store on the next handshake, no restart required. (own / issuer remain read-only.) The actions are filesystem moves/deletes performed by `CertificateStoreManager`, which finds the target by matching the certificate thumbprint — never by building a path from caller input.
|
|
|
|
### Production hardening
|
|
|
|
- Set `AutoAcceptUntrustedClientCertificates = false`.
|
|
- Drop `None` from `EnabledSecurityProfiles`.
|
|
- Promote trusted client certs with the Admin UI Certificates page's **Trust** action (FleetAdmin) — or by moving the `.der` from `rejected/` to `trusted/certs/` — rather than relying on the auto-accept fallback.
|
|
- Periodically audit the `rejected/` directory; an unexpected entry is often a misconfigured client or a probe attempt.
|
|
|
|
---
|
|
|
|
## OPC UA Authentication
|
|
|
|
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 | `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_<keyId>_<secret>` 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 `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`)
|
|
|
|
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. The same `ILdapAuthService` instance serves **both** the Admin UI cookie login (`/auth/login`) and the OPC UA UserName path (via `LdapOpcUaUserAuthenticator`), so operators use one credential across both planes.
|
|
|
|
`OtOpcUaLdapAuthService.AuthenticateAsync`:
|
|
|
|
1. If `Enabled = false`, denies outright — no bind, no DevStub bypass (the master switch wins).
|
|
2. If `DevStubMode = true`, accepts any non-empty credentials and grants the `Administrator` role **without any network bind** (dev only — must be `false` in production).
|
|
3. Refuses to bind over a plaintext transport (`Transport = None`) unless `AllowInsecure = true` (dev/test only). This is enforced at login, not at startup.
|
|
4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships.
|
|
5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password".
|
|
|
|
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity (`RoleCarryingUserIdentity`), where the write and alarm-ack gates read them. **The LDAP group names themselves are consumed here and go no further** — which is one reason the `NodeAcl` subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
|
|
|
|
`Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production):
|
|
|
|
```json
|
|
{
|
|
"Security": {
|
|
"Ldap": {
|
|
"Enabled": true,
|
|
"DevStubMode": false,
|
|
"Server": "dc01.corp.example.com",
|
|
"Port": 636,
|
|
"Transport": "Ldaps",
|
|
"AllowInsecure": false,
|
|
"SearchBase": "DC=corp,DC=example,DC=com",
|
|
"ServiceAccountDn": "CN=OtOpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
|
"ServiceAccountPassword": "<from your secret store>",
|
|
"GroupAttribute": "memberOf",
|
|
"DisplayNameAttribute": "cn",
|
|
"UserNameAttribute": "sAMAccountName",
|
|
"GroupToRole": {
|
|
"OPCUA-Designers": "Designer",
|
|
"OPCUA-Admins": "Administrator",
|
|
"OPCUA-Operators": "Operator"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`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.
|
|
|
|
### API-key identity (OPC UA data plane)
|
|
|
|
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_<keyId>_<secret>
|
|
```
|
|
|
|
`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. Validated `> 0` at startup by `LdapOptionsValidator`. |
|
|
|
|
|
|
|
|
---
|
|
|
|
## Data-Plane Authorization
|
|
|
|
> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the
|
|
> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The
|
|
> evaluator is built and unit-tested but has **zero production call sites**, and
|
|
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything
|
|
> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is
|
|
> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie).
|
|
|
|
### What is enforced today
|
|
|
|
There are exactly **three** authorization checks in the whole OPC UA server layer. All three are
|
|
coarse, **server-wide role string checks** with no per-node component.
|
|
|
|
| Operation | Gate | Requires |
|
|
|---|---|---|
|
|
| Write (Value attribute, either namespace) | `OtOpcUaNodeManager.EvaluateEquipmentWriteGate` | role `WriteOperate` |
|
|
| Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) | `OtOpcUaNodeManager.HandleAlarmCommand` | role `AlarmAck` |
|
|
| Alarm acknowledge (driver-fed native alarms) | `OtOpcUaNodeManager.HandleNativeAlarmAck` | role `AlarmAck` |
|
|
|
|
Both gates fail closed — a session with no identity or no matching role gets
|
|
`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity`
|
|
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are
|
|
the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see
|
|
[Role grant source](#role-grant-source-data-plane)).
|
|
|
|
**What has no authorization check at all:**
|
|
|
|
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
|
|
an Anonymous one, sees and reads the entire address space.
|
|
- **HistoryRead** — all four overrides (`HistoryReadRawModified`, `HistoryReadProcessed`,
|
|
`HistoryReadAtTime`, `HistoryReadEvents`) run without an identity check.
|
|
- **CreateMonitoredItems / subscriptions / TransferSubscriptions**.
|
|
- **Non-Value attribute writes** — the gate hangs off `OnWriteValue`.
|
|
|
|
**And what the write gate does *not* distinguish:**
|
|
|
|
- **`WriteTune` and `WriteConfigure` are never checked.** They exist in the role vocabulary and in
|
|
`NodePermissions`, but every write — whatever the tag's security classification — is gated on the
|
|
single `WriteOperate` string.
|
|
- **`AlarmAcknowledge` / `AlarmConfirm` / `AlarmShelve` are not distinguished.** One `AlarmAck` role
|
|
covers all five alarm methods; the `operation` argument is passed to the router but never consulted
|
|
by the gate.
|
|
- **There is no per-node granularity.** A session that may write one tag may write **every** writable
|
|
tag on the node.
|
|
|
|
The gate *is* realm-aware in one narrow sense: it is attached per-node during materialization for both
|
|
the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself
|
|
takes no realm and no node.
|
|
|
|
### Designed but not wired: `NodeAcl` + `PermissionTrie`
|
|
|
|
Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that
|
|
**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design
|
|
is sound and the remaining work is wiring plus one genuine design gap — not because it runs.
|
|
|
|
What is real about it:
|
|
|
|
- Operators **can** author `NodeAcl` rows in the Admin UI (`/clusters/{id}/acls`), and those rows are
|
|
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 **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 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"):
|
|
|
|
1. ~~**Raw-realm nodes have no representable scope.**~~ **Closed by #520 stage 3.** `NodeScope` is now
|
|
`(ClusterId, Kind, Segments)` with a variable-length path, and `NodeHierarchyKind` carries both
|
|
`Uns` and `Raw`. The previous fixed-arity shape could not address a real raw tree at all — folder
|
|
chains and tag-group chains both nest without bound — so it silently capped at five levels and
|
|
mislabelled every one of them. Four `Raw*` scope kinds were appended for authoring.
|
|
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.**~~ **Closed by #520 stage 3.**
|
|
`AclScopePathResolver` is that producer, built on both sides of the split — EF entities at the deploy
|
|
gate, artifact JSON on the node — and `scopePaths` is now a required argument with no fallback: an
|
|
unresolvable scope is **dropped with a diagnostic** rather than planted at a trie position the walk
|
|
can never reach. A scope resolves to *several* paths when one entity is reachable at several NodeIds
|
|
(a raw tag projected into N equipments has one raw path plus N UNS paths), because a grant that
|
|
covered only some of them would be bypassable through the one it missed.
|
|
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
|
|
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.
|
|
|
|
`NodeAclScopeKind.FolderSegment` is the retired v2 kind with no v3 entity behind it. It is still offered
|
|
in the authoring dropdown (labelled retired), but it can never resolve, and both ends now say so
|
|
specifically rather than reporting it as a missing id: the deploy gate rejects it with
|
|
`NodeAclScopeKindRetired`, and the trie builder drops it with a `RetiredScopeKind` diagnostic (Gitea
|
|
#533 — it used to arrive as `UnresolvedScope`, which sent an operator holding a legacy row hunting for a
|
|
typo in an id that was never the problem). One inconsistency remains: `NodePermissions.AlarmRead` has no
|
|
`OpcUaOperation` mapping, so it is grantable but unreachable.
|
|
|
|
**String identity in this subsystem** (Gitea #533): **identifiers** — `ScopeId`, `ClusterId`,
|
|
`Principal` — compare `OrdinalIgnoreCase`, because they are opaque handles whose spelling genuinely
|
|
varies by source (a `ClusterId` arrives lower-cased through `OTOPCUA_ROLES` and verbatim through
|
|
`Cluster:Roles`; a `ScopeId` is operator free-text until the stage-7 picker lands). **Path segments** —
|
|
every level of a scope path, and therefore `PermissionTrieNode.Children` — compare `Ordinal`, because
|
|
they are browse names and `Speed` / `speed` are two different nodes that can coexist under one device.
|
|
The rule is stated in full on `AclScopeRef`. It matters most where the deploy gate and the node are fed
|
|
from *different* snapshots, which is exactly the split `AclScopePathResolver` exists to bridge: case
|
|
drift there would silently drop every grant on the affected scope.
|
|
|
|
### Hierarchy (design)
|
|
|
|
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
|
|
|
|
```
|
|
Equipment (UNS) kind: Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag
|
|
SystemPlatform (Galaxy) kind: Cluster → Namespace → FolderSegment(s) → Tag
|
|
```
|
|
|
|
On the Galaxy/SystemPlatform path each folder segment takes one trie level, so a deeply-nested Galaxy folder reaches the same depth as a full UNS path. Unset mid-path levels leave the corresponding id `null` and the evaluator walks only as far as the scope goes.
|
|
|
|
Each level can carry `NodeAcl` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs`) that grant a permission bundle to a set of `LdapGroups`.
|
|
|
|
### Permission flags
|
|
|
|
`NodePermissions` (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs`), stored as an `int` bitmask in `NodeAcl.PermissionFlags`:
|
|
|
|
```csharp
|
|
[Flags]
|
|
public enum NodePermissions : int
|
|
{
|
|
None = 0,
|
|
|
|
Browse = 1 << 0,
|
|
Read = 1 << 1,
|
|
Subscribe = 1 << 2,
|
|
HistoryRead = 1 << 3,
|
|
WriteOperate = 1 << 4,
|
|
WriteTune = 1 << 5,
|
|
WriteConfigure = 1 << 6,
|
|
AlarmRead = 1 << 7,
|
|
AlarmAcknowledge = 1 << 8,
|
|
AlarmConfirm = 1 << 9,
|
|
AlarmShelve = 1 << 10,
|
|
MethodCall = 1 << 11,
|
|
HistoryUpdate = 1 << 12, // OPC UA annotation / insert / delete (separate from HistoryRead)
|
|
|
|
ReadOnly = Browse | Read | Subscribe | HistoryRead | AlarmRead,
|
|
Operator = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
|
|
Engineer = Operator | WriteTune | AlarmShelve,
|
|
Admin = Engineer | WriteConfigure | MethodCall,
|
|
}
|
|
```
|
|
|
|
The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAccess`/`Operate` → `WriteOperate`, `Tune` → `WriteTune`, `Configure` → `WriteConfigure`. `SecuredWrite` / `VerifiedWrite` / `ViewOnly` classifications remain read-only from OPC UA regardless of grant.
|
|
|
|
`HistoryUpdate` (bit 12) is a **separate** permission from `HistoryRead` so a read-only grant cannot also authorize OPC UA HistoryUpdate (annotation / insert / delete) operations. `TriePermissionEvaluator` maps `OpcUaOperation.HistoryUpdate` to this bit, closing the read⇒update hole that existed when the only history permission was `HistoryRead`. Note: `HistoryUpdate` is **not** included in any composite bundle (`ReadOnly` / `Operator` / `Engineer` / `Admin`) because the HistoryUpdate service surface (the actual insert/replace/delete backend RPC) is not yet implemented — a client calling HistoryUpdate still receives the SDK's default reject regardless of the grant.
|
|
|
|
### Evaluator — `PermissionTrie`
|
|
|
|
`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/`:
|
|
|
|
| Class | Role |
|
|
|---|---|
|
|
| `PermissionTrie` | Cluster-scoped trie; each node carries `(GroupId → NodePermissions)` grants. |
|
|
| `PermissionTrieBuilder` | Builds a trie from the current `NodeAcl` rows in one pass and installs it into the cache. |
|
|
| `PermissionTrieCache` | Process-singleton cache keyed on `(ClusterId, GenerationId)`. Generation-sealed: `Install(trie)` adds a new generation + advances the "current" pointer; older generations are retained (in-flight requests still resolve) and GC'd by `Prune`. `Invalidate(clusterId)` drops every cached trie for a cluster. There is **no** `AclChangeNotifier` — a publish installs a new generation rather than signalling an invalidation. |
|
|
| `TriePermissionEvaluator` | Implements `IPermissionEvaluator.Authorize(session, operation, scope)`. Walks the cluster trie for the supplied `NodeScope`, unions grants along the path, and returns an `AuthorizationDecision`. Evaluates against the **session's bound generation** (`session.AuthGenerationId`), not just "current", so a grant added/removed in a newer generation cannot take effect mid-session. |
|
|
|
|
`NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute.
|
|
|
|
### Dispatch gate — `IPermissionEvaluator` (design; **no such gate exists**)
|
|
|
|
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`.
|
|
|
|
⚠️ **The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built.** `Authorize` has zero production call sites. What the dispatch path really does is in [What is enforced today](#what-is-enforced-today).
|
|
|
|
Intended properties, of which only the last is true today:
|
|
|
|
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else was to flow through the evaluator.
|
|
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.**
|
|
- **Evaluator stays pure.** ✅ True — `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired.
|
|
|
|
### Full model
|
|
|
|
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a **design document for unshipped work**, not as a description of running behaviour.
|
|
|
|
Note also that the `SystemPlatform` hierarchy kind named above was retired with the v3 dual-namespace address space; `NodeHierarchyKind` now has only `Equipment`.
|
|
|
|
### Role grant source (data-plane)
|
|
|
|
Data-plane roles come from `Security:Ldap:GroupToRole` (appsettings), **not** from the Config-DB
|
|
`LdapGroupRoleMapping` table. That table's `Role` column is the `AdminRole` enum
|
|
(`Administrator`/`Designer`/`Viewer`) and supplies **control-plane** roles only — it cannot emit the
|
|
data-plane role strings the OPC UA gates read (`ReadOnly`, `WriteOperate`, `WriteTune`,
|
|
`WriteConfigure`, `AlarmAck`). A deployment therefore **must** map its data-plane LDAP groups
|
|
to those role strings via `GroupToRole`, e.g.:
|
|
|
|
```json
|
|
"GroupToRole": {
|
|
"ot-operators": "WriteOperate",
|
|
"ot-alarm-ack": "AlarmAck"
|
|
}
|
|
```
|
|
|
|
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
|
|
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
|
|
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
|
|
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for
|
|
everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was
|
|
wrong for three of them.
|
|
|
|
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
|
|
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
|
|
the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses,
|
|
subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry.
|
|
|
|
The two live role strings are exact and case-insensitive. In particular the alarm-ack role is
|
|
`AlarmAck`, **not** `AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different
|
|
vocabulary); a `GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
|
|
|
|
---
|
|
|
|
## Control-Plane Authorization
|
|
|
|
Control-plane authorization governs **the Admin UI** — who can view fleet config, edit drafts, publish generations, manage cluster nodes + credentials.
|
|
|
|
Per decision #150 control-plane roles are **deliberately independent of data-plane ACLs**. An operator who can read every OPC UA tag in production may not be allowed to edit cluster config; conversely a `Designer` may not have any data-plane grants at all.
|
|
|
|
### Roles
|
|
|
|
The `AdminRole` enum (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs`) defines three roles. Task 1.7 standardized the member names on the canonical `ZB.MOM.WW.Auth` `CanonicalRole` vocabulary (`ConfigViewer → Viewer`, `ConfigEditor → Designer`, `FleetAdmin → Administrator`); a data migration (`CanonicalizeAdminRoles`) rewrote existing rows. This was a rename, not a permission change.
|
|
|
|
| Role | Capabilities |
|
|
|---|---|
|
|
| `Viewer` | Read-only access to drafts, generations, audit log, fleet status. (Was `ConfigViewer`.) |
|
|
| `Designer` | Viewer plus draft authoring (UNS, equipment, tags, ACLs, driver instances, reservations, CSV imports). Cannot publish. (Was `ConfigEditor`.) |
|
|
| `Administrator` | Designer plus publish, cluster/node CRUD, credential management, role-grant management. Satisfies both the `FleetAdmin` and `DriverOperator` authorization policies. (Was `FleetAdmin`.) |
|
|
|
|
`DriverOperator` is an **authorization policy name** (kept stable), not an `AdminRole` member. It gates **Reconnect** / **Restart** commands against live driver instances from the Admin UI `DriverStatusPanel` and requires the canonical role `Operator` or `Administrator` (`policy.RequireRole("Operator", "Administrator")` in `AddAuthorization`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`). `Operator` is an appsettings-only string role (not an `AdminRole` member); map an LDAP group to it via `GroupToRole`, e.g. `"ot-driver-operator": "Operator"`. The `FleetAdmin` policy requires the `Administrator` role.
|
|
|
|
#### AdminUI page authorization policies (R2-05)
|
|
|
|
Every routable AdminUI page carries an explicit **named-policy** `@attribute [Authorize(Policy = …)]` — there are no bare `[Authorize]` (authenticated-only) config pages and no per-page `Roles="…"` strings. The policy names are constants on `AdminUiPolicies` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AdminUiPolicies.cs`), the single source of truth referenced by pages, `AuthorizeView` blocks, imperative `IAuthorizationService.AuthorizeAsync` checks, and endpoint groups (never string literals). All four are registered by `AddOtOpcUaAuth`:
|
|
|
|
| Policy | Satisfied by roles | Gates |
|
|
|---|---|---|
|
|
| `AdminUiPolicies.ConfigEditor` | `Administrator`, `Designer` | The config-authoring surface: `/uns`, equipment, cluster/node/namespace/ACL editors, all 8 driver pages (which author live retry/breaker `ResilienceConfig`), Deployments, Scripts, ScriptEdit, and the `/api/script-analysis/*` endpoint group. |
|
|
| `AdminUiPolicies.FleetAdmin` | `Administrator` | `RoleGrants` page; the `Certificates` Trust/Untrust/Delete actions (`AuthorizeView` + server-side re-check). |
|
|
| `AdminUiPolicies.DriverOperator` | `Operator`, `Administrator` | Imperative live-ops actions only (Alerts ack/shelve, DriverStatusPanel reconnect/restart, live address pickers). |
|
|
| `AdminUiPolicies.AuthenticatedRead` | any authenticated user | The read-only dashboards, lists, and live tails (Home, Fleet, Hosts, Alerts, Certificates, cluster read views, etc.). Semantically identical to the `FallbackPolicy`; named so read pages carry a non-bare attribute. |
|
|
|
|
`Login` is the only `[AllowAnonymous]` page (Admin-001). A reflection guard — `PageAuthorizationGuardTests` (`tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs`) — enumerates every routable type in the AdminUI assembly and fails the build if any page carries a bare `[Authorize]`, a `Roles="…"` idiom, an unknown policy name, or is missing from the classification map. This is the anti-drift anchor (the repo has no bUnit): a new page fails the test until its author consciously classifies it. `AdminUiPoliciesTests` (`tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AdminUiPoliciesTests.cs`) pins each policy's role semantics.
|
|
|
|
In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUaAuth` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`), which also installs a `FallbackPolicy` that requires an authenticated user. Razor pages gate inline with the named-policy attribute above, e.g. `@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]`. Nav-menu sections hide via `<AuthorizeView>`.
|
|
|
|
### Role grant source
|
|
|
|
Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI.
|
|
|
|
### Headless deploy API (`POST /api/deployments`)
|
|
|
|
For CI / scripts that need to trigger a deployment without driving the Blazor "Deploy current configuration" button, admin-role nodes expose `POST /api/deployments` (`DeployApiEndpoints`, `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs`). It forwards to the same `IAdminOperationsClient.StartDeploymentAsync` the button calls.
|
|
|
|
Auth is a **single configured secret** checked from the `X-Api-Key` header in fixed time — deliberately orthogonal to the cookie-only web auth (`OPC UA Authentication` above) so automation needs no LDAP login round-trip. The endpoint is `AllowAnonymous` so the `FallbackPolicy` doesn't 401 it, and enforces the key itself. **It self-disables (503) until `Security:DeployApiKey` is set**, so it is never open by default.
|
|
|
|
```bash
|
|
curl -X POST https://<admin-host>/api/deployments \
|
|
-H 'X-Api-Key: <Security:DeployApiKey>' \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"createdBy":"ci-bot"}'
|
|
```
|
|
|
|
Responses: `202 Accepted` (`{ outcome, deploymentId, revisionHash }`) when a deployment was sealed, `200` for `NoChanges`, `409` when another deployment is in flight, `422` when rejected, `401` for a missing/wrong key, `503` when unconfigured. Set the secret via `Security:DeployApiKey` (env `Security__DeployApiKey`) on admin nodes only; treat it like any deploy credential (rotate, keep out of source).
|
|
|
|
---
|
|
|
|
## OTOPCUA0001 Analyzer — Compile-Time Guard
|
|
|
|
Per-capability resilience (retry, timeout, circuit-breaker) is applied by `CapabilityInvoker` in `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/`. A driver-capability call made **outside** the invoker bypasses resilience entirely — which in production looks like inconsistent timeouts, un-wrapped retries, and unbounded blocking.
|
|
|
|
`OTOPCUA0001` (Roslyn analyzer at `src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs`) fires with category `OtOpcUa.Resilience` and default severity **Warning** (per `AnalyzerReleases.Shipped.md`) when a method on one of the seven guarded capability interfaces (`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider` — all in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`) is invoked **outside** a lambda passed to `CapabilityInvoker.ExecuteAsync` / `ExecuteWriteAsync`. `AlarmSurfaceInvoker` is **not** a wrapper home — its own implementation is covered transitively because it routes through the inner `CapabilityInvoker.ExecuteAsync`. The analyzer walks up the syntax tree from the call site, finds any enclosing invoker invocation, and verifies the call lives transitively inside that invocation's anonymous-function argument — a sibling pattern (do the call, then invoke `ExecuteAsync` on something unrelated nearby) does not satisfy the rule.
|
|
|
|
The xunit.v3 + Shouldly suite at `tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs` covers the common fail/pass shapes + the sibling-pattern regression guard.
|
|
|
|
The rule is intentionally scoped to async surfaces — pure in-memory accessors like `IHostConnectivityProbe.GetHostStatuses()` return synchronously and do not require the invoker wrap.
|
|
|
|
---
|
|
|
|
## Write-Outcome Self-Correction
|
|
|
|
When an OPC UA client writes a value and the driver reports a failure, the server automatically
|
|
reverts the variable node to its prior value and surfaces the Bad-quality status code to the client.
|
|
This prevents the node from showing a phantom Good value after a device-level write error. The
|
|
revert is local to the node that received the write — no cluster coordination is required.
|
|
|
|
Additionally, an `AuditWriteUpdateEvent` is emitted on every write attempt (success or failure),
|
|
consistent with OPC UA Part 4 audit requirements. The event carries the source node id, the
|
|
requested value, and the final status code so audit log consumers can trace write outcomes without
|
|
polling the node.
|
|
|
|
(Implemented in master `1d797c1c`.)
|
|
|
|
---
|
|
|
|
## Audit Logging
|
|
|
|
- **Server**: authentication, certificate-validation, write, and write-denial events are logged through the regular Serilog rolling file sink; write outcomes also emit an `AuditWriteUpdateEvent` (see [Write-Outcome Self-Correction](#write-outcome-self-correction)).
|
|
- **Admin**: `AuditWriterActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`) writes `ConfigAuditLog` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs`) to the Config DB for publish, rollback, cluster-node CRUD, and credential rotation. Visible on the cluster Audit page (`ClusterAudit.razor`) for operators with `Viewer` or above.
|
|
|
|
---
|
|
|
|
## Config secrets (`${secret:}` delivery)
|
|
|
|
OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is
|
|
supplied at deploy time — either as a plain environment variable, or as a
|
|
`${secret:<name>}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store.
|
|
|
|
A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in
|
|
`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every
|
|
`${secret:<name>}` token into its resolved plaintext **before** the host is built —
|
|
so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first
|
|
`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from
|
|
the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`);
|
|
the encrypted SQLite store lives at `Secrets:SqlitePath`.
|
|
|
|
The expander is **fail-closed and section-agnostic**: a `${secret:<name>}` token whose
|
|
secret is absent throws `SecretNotFoundException` at startup, regardless of which feature
|
|
owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are
|
|
skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved.
|
|
|
|
The five config secrets and their canonical secret names:
|
|
|
|
| Config key | Owner | Secret name |
|
|
|---|---|---|
|
|
| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` |
|
|
| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` |
|
|
| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` |
|
|
| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` |
|
|
| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` |
|
|
|
|
**Delivery.** By default these are delivered as plain environment variables (e.g.
|
|
`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted
|
|
store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply
|
|
the token in place of the literal — via env var or a deployment appsettings overlay:
|
|
|
|
```
|
|
secret set otopcua/historian/api-key <value> # seed the encrypted store first
|
|
ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}'
|
|
```
|
|
|
|
Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded
|
|
token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any
|
|
secret value.
|
|
|
|
For the production posture needed so every clustered node (admin, driver, and any
|
|
fused role) resolves the same secrets from the same store, see
|
|
[`docs/operations/2026-07-16-secrets-clustered-master-key.md`](operations/2026-07-16-secrets-clustered-master-key.md).
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Certificate trust failure
|
|
|
|
Check `{PkiStoreRoot}/rejected/` for the client's cert. As a FleetAdmin, use the Admin UI Certificates page's **Trust** action to move it into `trusted/` (or copy the `.der` to `trusted/certs/` by hand); the SDK trust list reloads on the next handshake.
|
|
|
|
### LDAP users can connect but fail authorization
|
|
|
|
Verify (a) `Security:Ldap:GroupAttribute` (default `memberOf`) returns the user's groups, (b) `Security:Ldap:GroupToRole` maps those groups to the expected roles, and (c) a `NodeAcl` grant exists at some level of the node's scope path that unions to the required permission. The data-plane evaluator is strictly default-deny — there is no fail-open mode to fall back on.
|
|
|
|
### LDAP bind rejected as "insecure"
|
|
|
|
Set `Security:Ldap:Transport = "Ldaps"` (or `"StartTls"`) with the matching port (636 for AD `Ldaps`), or temporarily set `Security:Ldap:AllowInsecure = true` in dev. Production Active Directory increasingly refuses plain-LDAP bind under LDAP-signing enforcement.
|
|
|
|
### Stale ACL trie after a publish
|
|
|
|
A publish installs a **new generation** into `PermissionTrieCache` via `PermissionTrieBuilder` rather than signalling an invalidation; the evaluator binds each session to a generation. If grants appear stale, confirm the new generation was installed (publish completed) and that sessions re-resolved their auth state — a session past its staleness ceiling fails closed and must re-authenticate. As a last resort `PermissionTrieCache.Invalidate(clusterId)` drops a cluster's cached tries.
|