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>
116 lines
5.3 KiB
C#
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);
|
|
}
|
|
}
|