From fb6dd3478dc6e8076814d2644dd3a4585df899d4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Apr 2026 15:35:46 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206.2=20Stream=20C=20wiring=20=E2=80=94?= =?UTF-8?q?=20AuthorizationBootstrap=20+=20OpcUaApplicationHost.SetAuthori?= =?UTF-8?q?zation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes task #133 — the "authz gate is inert in production" blocker surfaced during task #123. Before this commit, every ACL check on the six dispatch surfaces (Read, Write, HistoryRead, Browse, CreateMonitoredItems, Call) short-circuited to allow because Program.cs constructed OpcUaApplicationHost without passing authzGate or scopeResolver. New pieces: - `AuthorizationOptions` — bound to `Node:Authorization` in appsettings.json. `Enabled` (default false) is the master switch; `StrictMode` (default false) controls the anonymous / no-LDAP-groups fallback behaviour. - `AuthorizationBootstrap` — singleton service that loads `NodeAcl` rows for the published generation, builds a `PermissionTrieCache` + `AuthorizationGate`, merges every registered driver's `EquipmentNamespaceContent` through `ScopePathIndexBuilder` into one full-path `NodeScopeResolver`. Returns `(null, null)` when disabled or when no generation is Published yet. - `DriverEquipmentContentRegistry.Snapshot()` — new method returning a defensive copy of the driver → content map so the bootstrap can iterate without holding the lock. - `OpcUaApplicationHost.SetAuthorization(gate, resolver)` — late-bind method matching the existing `SetPhase7Sources` pattern. Must run before `StartAsync`; rejects post-start rebinding with InvalidOperationException. - `OpcUaServerService.ExecuteAsync` calls `AuthorizationBootstrap.BuildAsync` after `PopulateEquipmentContentAsync` and before `applicationHost.StartAsync`, in the same window that `SetPhase7Sources` runs. Behaviour change - Default (Enabled=false): no behaviour change — the gate stays null, all six dispatch surfaces run unchanged. Safe for any existing deployment on upgrade. - Enabled=true with StrictMode=false: identities carrying LDAP groups are evaluated against the trie; anonymous / no-groups identities pass through (v1 legacy-client compatibility). - Enabled=true with StrictMode=true: everything evaluates. Anonymous or no-groups identities are denied. Follow-up not covered here: rebind the gate+resolver on generation refresh (the `GenerationRefreshHostedService` that shipped earlier in this session). Today the gate only reflects the bootstrap generation — operators publishing new ACL changes need a process restart to see them. Matches the current driver-hot-reload limitation and is tracked in the existing 6.3 follow-up bullet. Docs: v2-release-readiness.md Phase 6.2 Stream C.12 bullet flipped to Closed with operator-facing config pointer (`Node:Authorization:Enabled`). All 283/283 Server.Tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/v2-release-readiness.md | 2 +- src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs | 5 + .../OpcUa/DriverEquipmentContentRegistry.cs | 13 ++ .../OpcUa/OpcUaApplicationHost.cs | 21 +++- .../OpcUaServerService.cs | 11 ++ src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 5 + .../Security/AuthorizationBootstrap.cs | 115 ++++++++++++++++++ .../Security/AuthorizationOptions.cs | 33 +++++ 8 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationOptions.cs diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index 6a1bc85..56e9f5b 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -38,7 +38,7 @@ Remaining Stream C surfaces (hardening, not release-blocking): - ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern). - ~~Alarm Acknowledge / Confirm / Shelve gating.~~ **Partial, 2026-04-24.** Acknowledge + Confirm map to dedicated `OpcUaOperation.AlarmAcknowledge` / `AlarmConfirm` via `MapCallOperation`; Shelve falls through to generic `OpcUaOperation.Call` (needs per-instance method NodeId resolution to distinguish — follow-up). - ~~Call (method invocation) gating.~~ **Closed 2026-04-24.** `DriverNodeManager.Call` override pre-gates each `CallMethodRequest` via `GateCallMethodRequests`. Denied calls return `BadUserAccessDenied` without running the method. Alarm methods map to alarm-specific operation kinds; everything else gates as generic `Call`. -- ~~Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.~~ **Partial, 2026-04-24.** `ScopePathIndexBuilder` + indexed-mode `NodeScopeResolver` exist and are unit-tested — index keys driver-side full-ref → full `Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag` scope. **Critical follow-up (task #133):** Program.cs does not yet construct either the gate or the resolver — all six dispatch-layer gates (Read, Write, HistoryRead, Browse, CreateMonitoredItems, Call) are currently inert in production. Wiring is required before GA. +- ~~Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.~~ **Closed 2026-04-24.** `AuthorizationBootstrap` now loads `NodeAcl` rows for the current generation into a `PermissionTrieCache`, builds the gate, and merges every registered driver's `EquipmentNamespaceContent` into a full-path `NodeScopeResolver` index. `OpcUaServerService` calls the bootstrap after the equipment registry is populated, before `OpcUaApplicationHost.StartAsync`. Disabled by default — operators flip `Node:Authorization:Enabled=true` to enforce, `StrictMode=true` to reject anonymous/no-groups identities. - 3-user integration matrix covering every operation × allow/deny. ### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96) diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs index 0127e73..8847f6c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Server.Security; + namespace ZB.MOM.WW.OtOpcUa.Server; /// @@ -20,4 +22,7 @@ public sealed class NodeOptions /// Path to the LiteDB local cache file. public string LocalCachePath { get; init; } = "config_cache.db"; + + /// Phase 6.2 authorization pipeline config. Disabled by default. + public AuthorizationOptions Authorization { get; init; } = new(); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs index 822c6c8..327910b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverEquipmentContentRegistry.cs @@ -44,4 +44,17 @@ public sealed class DriverEquipmentContentRegistry { get { lock (_lock) { return _content.Count; } } } + + /// + /// Snapshot the current driver → content map. Returns a copy so callers can iterate + /// without holding the lock. Used at authorization bootstrap to merge all namespaces + /// into a single path index. + /// + public IReadOnlyDictionary Snapshot() + { + lock (_lock) + { + return new Dictionary(_content, StringComparer.OrdinalIgnoreCase); + } + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index be26535..a1a7abf 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -24,8 +24,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private readonly DriverHost _driverHost; private readonly IUserAuthenticator _authenticator; private readonly DriverResiliencePipelineBuilder _pipelineBuilder; - private readonly AuthorizationGate? _authzGate; - private readonly NodeScopeResolver? _scopeResolver; + private AuthorizationGate? _authzGate; + private NodeScopeResolver? _scopeResolver; private readonly StaleConfigFlag? _staleConfigFlag; private readonly Func? _tierLookup; private readonly Func? _resilienceConfigLookup; @@ -95,6 +95,23 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _scriptedAlarmReadable = scriptedAlarmReadable; } + /// + /// Late-bind the Phase 6.2 authorization gate + node-scope resolver. Must be called + /// BEFORE — once the OPC UA server starts the + /// + per-namespace s + /// capture these fields and later rebinding has no effect on already-materialized + /// managers. Call with null for either parameter to leave the corresponding + /// pipeline inert. + /// + public void SetAuthorization(AuthorizationGate? gate, NodeScopeResolver? resolver) + { + if (_server is not null) + throw new InvalidOperationException( + "Authorization must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values."); + _authzGate = gate; + _scopeResolver = resolver; + } + /// /// Builds the , validates/creates the application /// certificate, constructs + starts the , then drives diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs index c988ee6..ca7cd66 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Phase7; +using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server; @@ -20,6 +21,7 @@ public sealed class OpcUaServerService( DriverEquipmentContentRegistry equipmentContentRegistry, DriverInstanceBootstrapper driverBootstrapper, Phase7Composer phase7Composer, + AuthorizationBootstrap authorizationBootstrap, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { @@ -55,6 +57,15 @@ public sealed class OpcUaServerService( // No-op when the generation has no virtual tags or scripted alarms. var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken); applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable); + + // Phase 6.2 Stream C wiring — build the AuthorizationGate + NodeScopeResolver + // from the published generation's NodeAcl rows and the populated equipment + // registry. No-op when Node:Authorization:Enabled=false. Must run before + // StartAsync: OtOpcUaServer + DriverNodeManager construction captures the + // field values on the application host. + var (authzGate, scopeResolver) = await authorizationBootstrap + .BuildAsync(gen, stoppingToken).ConfigureAwait(false); + applicationHost.SetAuthorization(authzGate, scopeResolver); } await applicationHost.StartAsync(stoppingToken); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index 3239146..79c4b6b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -123,6 +123,11 @@ builder.Services.AddSingleton(); // added to OpcUaApplicationHost's ctor seam. builder.Services.AddSingleton(); builder.Services.AddScoped(); +// Phase 6.2 Stream C wiring — constructs AuthorizationGate + NodeScopeResolver from the +// published generation's NodeAcl rows + per-driver EquipmentNamespaceContent. Gated by +// NodeOptions.Authorization.Enabled (default false) so existing deployments don't flip +// to ACL enforcement accidentally on upgrade. +builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs new file mode 100644 index 0000000..de02d31 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs @@ -0,0 +1,115 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Core.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.Security; + +/// +/// Bootstraps the Phase 6.2 authorization pipeline for the running Server. Loads +/// NodeAcl rows for the current generation into a +/// , constructs an , +/// and merges per-namespace into a single +/// full-path index for . +/// +/// +/// +/// Called by OpcUaServerService.ExecuteAsync after the +/// has been populated but before +/// OpcUaApplicationHost.StartAsync runs — that's the window where the +/// config-DB state is known + the OPC UA server hasn't yet captured the gate +/// references. +/// +/// +/// gates the whole flow. When +/// false (default), returns (null, null) +/// and the dispatch layer short-circuits every ACL check — identical to +/// pre-Phase-6.2. +/// +/// +public sealed class AuthorizationBootstrap( + IDbContextFactory dbFactory, + DriverEquipmentContentRegistry equipmentContentRegistry, + NodeOptions nodeOptions, + ILogger logger) +{ + /// + /// Build a gate + resolver pair for the supplied . + /// Returns (null, null) when authorization is disabled via + /// or when the generation couldn't be + /// fetched — in that case the dispatch layer runs without ACL enforcement (same + /// behaviour the Server had before Phase 6.2 Stream C landed). + /// + public async Task<(AuthorizationGate?, NodeScopeResolver?)> BuildAsync( + long? generationId, CancellationToken cancellationToken) + { + if (!nodeOptions.Authorization.Enabled) + { + logger.LogInformation( + "Authorization disabled (Node:Authorization:Enabled=false); all ACL gates remain inert"); + return (null, null); + } + + if (generationId is not long gen) + { + logger.LogWarning( + "Authorization enabled but no Published generation available — ACL enforcement skipped until next publish"); + return (null, null); + } + + var gate = await BuildGateAsync(gen, cancellationToken).ConfigureAwait(false); + var resolver = BuildResolver(); + + logger.LogInformation( + "Authorization pipeline bootstrapped — generation {Gen}, strictMode={Strict}", + gen, nodeOptions.Authorization.StrictMode); + + return (gate, resolver); + } + + /// + /// Load every row for + /// scoped to this node's cluster, build a + /// , construct an . + /// + private async Task BuildGateAsync(long generationId, CancellationToken cancellationToken) + { + await using var ctx = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var rows = await ctx.NodeAcls + .AsNoTracking() + .Where(a => a.ClusterId == nodeOptions.ClusterId && a.GenerationId == generationId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var cache = new PermissionTrieCache(); + cache.Install(PermissionTrieBuilder.Build(nodeOptions.ClusterId, generationId, rows)); + var evaluator = new TriePermissionEvaluator(cache); + return new AuthorizationGate(evaluator, strictMode: nodeOptions.Authorization.StrictMode); + } + + /// + /// Merge each registered driver's into a single + /// full-path index. Tag rows that cross-reference missing Equipment / Line / Area are + /// silently skipped (the cluster-only fallback handles them). Duplicate TagConfig + /// across namespaces is a config error — throws + /// on collision; we let that bubble so bootstrap fails fast. + /// + private NodeScopeResolver BuildResolver() + { + var merged = new Dictionary(StringComparer.Ordinal); + foreach (var kv in equipmentContentRegistry.Snapshot()) + { + // Namespace id isn't carried on EquipmentNamespaceContent directly — driverId + // serves as the namespace-stable key for ACL scope resolution. + var perNamespace = ScopePathIndexBuilder.Build(nodeOptions.ClusterId, kv.Key, kv.Value); + foreach (var entry in perNamespace) + merged[entry.Key] = entry.Value; + } + + return merged.Count == 0 + ? new NodeScopeResolver(nodeOptions.ClusterId) + : new NodeScopeResolver(nodeOptions.ClusterId, merged); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationOptions.cs new file mode 100644 index 0000000..ee73209 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationOptions.cs @@ -0,0 +1,33 @@ +namespace ZB.MOM.WW.OtOpcUa.Server.Security; + +/// +/// Configuration for the Phase 6.2 authorization pipeline. Bound from the +/// Node:Authorization section of appsettings.json. Defaults ship disabled +/// so upgrading from pre-Phase-6.2 doesn't accidentally start denying reads the day a +/// new build lands — operators opt in explicitly once their NodeAcl rows are +/// populated. +/// +/// +/// +/// is the master switch. When false (default), +/// the OPC UA application host constructs with +/// authzGate: null, scopeResolver: null; all six dispatch-layer gates +/// (Read, Write, HistoryRead, Browse, CreateMonitoredItems, Call) short-circuit +/// to pass — identical behaviour to pre-Phase-6.2. +/// +/// +/// When true, picks between two failure modes: +/// false (default) grants anonymous / no-LDAP-groups identities a pass- +/// through so v1-style legacy clients keep working; true denies them. +/// Production deployments should flip to StrictMode = true once every +/// client has been validated against the new identity flow. +/// +/// +public sealed class AuthorizationOptions +{ + /// Master switch. False = gate is inert; true = gate is wired into dispatch. + public bool Enabled { get; init; } + + /// False = anonymous / no-groups identities pass; true = they're denied. + public bool StrictMode { get; init; } +}