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); } }