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