Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs
Joseph Doherty a25593a9c6 chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:55:28 -04:00

116 lines
5.3 KiB
C#

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;
/// <summary>
/// Bootstraps the Phase 6.2 authorization pipeline for the running Server. Loads
/// <c>NodeAcl</c> rows for the current generation into a
/// <see cref="PermissionTrieCache"/>, constructs an <see cref="AuthorizationGate"/>,
/// and merges per-namespace <see cref="EquipmentNamespaceContent"/> into a single
/// full-path index for <see cref="NodeScopeResolver"/>.
/// </summary>
/// <remarks>
/// <para>
/// Called by <c>OpcUaServerService.ExecuteAsync</c> after the
/// <see cref="DriverEquipmentContentRegistry"/> has been populated but before
/// <c>OpcUaApplicationHost.StartAsync</c> runs — that's the window where the
/// config-DB state is known + the OPC UA server hasn't yet captured the gate
/// references.
/// </para>
/// <para>
/// <see cref="AuthorizationOptions.Enabled"/> gates the whole flow. When
/// <c>false</c> (default), <see cref="BuildAsync"/> returns <c>(null, null)</c>
/// and the dispatch layer short-circuits every ACL check — identical to
/// pre-Phase-6.2.
/// </para>
/// </remarks>
public sealed class AuthorizationBootstrap(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
DriverEquipmentContentRegistry equipmentContentRegistry,
NodeOptions nodeOptions,
ILogger<AuthorizationBootstrap> logger)
{
/// <summary>
/// Build a gate + resolver pair for the supplied <paramref name="generationId"/>.
/// Returns <c>(null, null)</c> when authorization is disabled via
/// <see cref="AuthorizationOptions.Enabled"/> 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).
/// </summary>
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);
}
/// <summary>
/// Load every <see cref="Configuration.Entities.NodeAcl"/> row for
/// <paramref name="generationId"/> scoped to this node's cluster, build a
/// <see cref="PermissionTrieCache"/>, construct an <see cref="AuthorizationGate"/>.
/// </summary>
private async Task<AuthorizationGate> 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);
}
/// <summary>
/// Merge each registered driver's <see cref="EquipmentNamespaceContent"/> 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 — <see cref="ScopePathIndexBuilder"/> throws
/// on collision; we let that bubble so bootstrap fails fast.
/// </summary>
private NodeScopeResolver BuildResolver()
{
var merged = new Dictionary<string, NodeScope>(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);
}
}