Merge branch 'master' into fix/832-docker-dev-batch

This commit is contained in:
Joseph Doherty
2026-11-11 02:32:42 -05:00
140 changed files with 6336 additions and 568 deletions
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
@@ -36,6 +37,13 @@ public sealed class BrowserSessionService(
/// <inheritdoc />
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
// lmxopcua#815: a live browse resolves the config's credential references and sends them to the
// author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the
// Discover-backed universal browser) is opened. Nothing is read from the store or environment here.
var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
if (credentialProblems.Count > 0)
return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
if (!_browsersByType.TryGetValue(driverType, out var browser))
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
try
@@ -23,6 +23,8 @@
<span class="text-muted small">Changes apply on the next deployment.</span>
</div>
<CredentialReferenceBanner />
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Project tree</span>
@@ -42,12 +42,14 @@ public sealed class HeidenhainDeviceModel
return TagConfigJson.Serialize(_bag);
}
/// <summary>
/// The endpoint's verdict (host / port). Credential fields are deliberately NOT reported here (lmxopcua#830):
/// the hosting <c>DeviceModal</c> shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which covers every
/// password field) once and disables Save — reporting it here too printed the same error twice.
/// </summary>
public string? Validate()
{
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList();
foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) })
if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v)))
errors.Add($"{name} password must be blank, secret:heidenhain/<name> or env:OTOPCUA_HEIDENHAIN_<NAME> — never a literal.");
var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port });
return errors.Count == 0 ? null : string.Join(" ", errors);
}
@@ -21,8 +21,8 @@
<div class="col-md-6">
<label class="form-label">API key secret ref</label>
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="env:MX_API_KEY" />
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
placeholder="env:OTOPCUA_GALAXY_API_KEY" />
<div class="form-text">Forms: <code>secret:galaxy/NAME</code>, <code>env:OTOPCUA_GALAXY_NAME</code> (or legacy <code>env:GALAXY_MXGW_NAME</code>), <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and <code>file:</code> are refused (#815).</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
@@ -107,7 +107,7 @@
<label class="form-label">Fallback password secret ref</label>
<InputText @bind-Value="_form.Galaxy.AckFallbackPasswordSecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="secret:otopcua/galaxy/ack-fallback-password" />
<div class="form-text">Forms: <code>secret:NAME</code> (production), <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:VALUE</code>. A bare literal is refused.</div>
<div class="form-text">Forms: <code>secret:galaxy/NAME</code> or <code>secret:otopcua/galaxy/NAME</code> (production), <code>env:OTOPCUA_GALAXY_NAME</code>, <code>dev:VALUE</code>. A bare literal, <code>file:</code>, or a reference outside the Galaxy namespace is refused (#815).</div>
</div>
</div>
</div>
@@ -220,7 +220,7 @@
}
private static GalaxyDriverOptions CreateDefaultOptions() => new(
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"),
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
Repository: new GalaxyRepositoryOptions(),
Reconnect: new GalaxyReconnectOptions());
@@ -241,7 +241,7 @@
{
// GalaxyGatewayOptions
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY";
public bool GatewayUseTls { get; set; } = true;
public string? GatewayCaCertificatePath { get; set; }
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
@@ -273,7 +273,7 @@
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
@@ -90,6 +90,10 @@
<label class="form-label" for="hhSlow">Slow (ms)</label>
<InputNumber id="hhSlow" @bind-Value="_m.SlowMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarmMs" title="Cadence of the alarm-list walk behind ALARM: Part 9 conditions; only polled when the control carries an ALARM: tag">Alarm conditions (ms)</label>
<InputNumber id="hhAlarmMs" @bind-Value="_m.AlarmMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="hhAlarms">Max alarms</label>
<InputNumber id="hhAlarms" @bind-Value="_m.MaxAlarms" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
@@ -22,6 +22,7 @@ public sealed class HeidenhainDriverFormModel
public int StatusMs { get; set; } = 1000;
public int CustomMs { get; set; } = 1000;
public int SlowMs { get; set; } = 10000;
public int AlarmMs { get; set; } = 2000;
public int MaxAlarms { get; set; } = 64;
public bool TimersEnabled { get; set; }
public int ReconnectInitialMs { get; set; } = 1000;
@@ -60,6 +61,7 @@ public sealed class HeidenhainDriverFormModel
StatusMs = o.Polling.StatusMs,
CustomMs = o.Polling.CustomMs,
SlowMs = o.Polling.SlowMs,
AlarmMs = o.Polling.AlarmMs,
MaxAlarms = o.Polling.MaxAlarms,
TimersEnabled = o.Polling.TimersEnabled,
ReconnectInitialMs = o.Reconnect.InitialDelayMs,
@@ -91,6 +93,7 @@ public sealed class HeidenhainDriverFormModel
["StatusMs"] = StatusMs,
["CustomMs"] = CustomMs,
["SlowMs"] = SlowMs,
["AlarmMs"] = AlarmMs,
["MaxAlarms"] = MaxAlarms,
["TimersEnabled"] = TimersEnabled,
};
@@ -98,13 +101,23 @@ public sealed class HeidenhainDriverFormModel
return _bag.ToJsonString(JsonOptions);
}
/// <summary>The driver's own verdict on this config (device endpoints are validated on the device form).</summary>
/// <summary>
/// The driver's own verdict on this config's ranges (device endpoints are validated on the device form).
/// Credential fields are deliberately NOT reported here (lmxopcua#830): the hosting <c>DriverConfigModal</c>
/// shows the save gate's refusal (<c>DriverConfigSaveGate</c>, which scans every password field of the stored
/// document) once and disables Save — reporting it here too printed the same error twice.
/// </summary>
public string? Validate()
{
var o = HeidenhainOptionsParser.Parse(ToJson());
var errors = HeidenhainOptionsParser.Validate(new HeidenhainDriverOptions
{
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds, Access = o.Access,
TimeoutMs = o.TimeoutMs, ProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
Access = new HeidenhainAccessOptions
{
TelemetryEnabled = o.Access.TelemetryEnabled, DncLoginEnabled = o.Access.DncLoginEnabled,
DataEnabled = o.Access.DataEnabled,
},
Polling = o.Polling, Reconnect = o.Reconnect,
});
return errors.Count == 0 ? null : string.Join(" ", errors);
@@ -138,6 +138,7 @@
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>. Other references are refused (#815).</div>
</div>
}
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
@@ -150,6 +151,7 @@
<div class="col-md-4">
<label class="form-label">Certificate password (if PFX-locked)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>.</div>
</div>
}
</div>
@@ -192,6 +194,15 @@
</div>
<div class="form-text">Revoked certificates are always rejected.</div>
</div>
<div class="col-md-3">
<label class="form-label">Server host-name check</label>
<InputSelect @bind-Value="_form.OpcUa.ValidateServerHostName" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
<option value="">Default (off)</option>
<option value="true">Validate configured host</option>
<option value="false">Off (NAT / port publish)</option>
</InputSelect>
<div class="form-text">Sign / SignAndEncrypt only (never on None): the server certificate must name the endpoint host you configured. Off for link-local IPv6.</div>
</div>
<div class="col-md-6">
<label class="form-label">Issuer store / CRL directory</label>
<InputText @bind-Value="_form.OpcUa.IssuerStorePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
@@ -349,6 +360,8 @@
public int SubscriptionPriority { get; set; }
public bool RejectUnknownRevocationStatus { get; set; }
public string? IssuerStorePath { get; set; }
// #816 tri-state: "" = unset (the driver default) so the form never re-emits a concrete default; "true"/"false".
public string ValidateServerHostName { get; set; } = "";
public string? DiscoveryUrl { get; set; }
public string? DiscoveryServerUri { get; set; }
@@ -381,6 +394,12 @@
SubscriptionPriority = r.SubscriptionPriority,
RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
ValidateServerHostName = r.CertificateValidation.ValidateServerHostName switch
{
true => "true",
false => "false",
null => "",
},
DiscoveryUrl = r.DiscoveryUrl,
DiscoveryServerUri = r.DiscoveryServerUri,
};
@@ -417,6 +436,12 @@
{
RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
ValidateServerHostName = ValidateServerHostName switch
{
"true" => true,
"false" => false,
_ => null,
},
},
DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri,
@@ -0,0 +1,85 @@
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Configuration
@inject CredentialReferencePreflightState State
@inject IDriverStatusSnapshotStore DriverStatus
@inject IServiceProvider Services
@* lmxopcua#815 upgrade preflight surface: stored / deployed configs whose credential references sit outside their
driver's namespace (the boot scan, re-runnable here) plus running drivers that reported resolving one through the
deprecated warn-only compatibility path (driver-health diagnostics, fleet-wide). Never shows a value. *@
@if (_findings.Count > 0 || _error is not null)
{
<div class="alert alert-warning small mb-3" role="alert">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<strong>Credential references outside their driver's namespace (lmxopcua#815).</strong>
The deploy gate refuses these; a driver deployed before the upgrade keeps resolving them in
deprecated warn-only mode until its next deploy. Migrate each to the allowed form — see
docs/security.md "Credential references are namespaced per driver".
</div>
<button class="btn btn-sm btn-outline-secondary" disabled="@_busy" @onclick="RecheckAsync">Re-check</button>
</div>
@if (_error is not null)
{
<div class="text-danger mt-1">@_error</div>
}
<ul class="mb-0 mt-2">
@foreach (var f in _findings.Take(MaxRows))
{
<li>
<span class="badge bg-secondary me-1">@f.Source</span>
@if (!string.IsNullOrEmpty(f.DriverType)) { <span>@f.DriverType </span> }
driver <span class="mono">@f.DriverName</span>
@if (f.DeviceName is not null) { <span> device <span class="mono">@f.DeviceName</span></span> }
— @f.Problem
</li>
}
</ul>
@if (_findings.Count > MaxRows)
{
<div class="mt-1 text-muted">…and @(_findings.Count - MaxRows) more (see the node log).</div>
}
</div>
}
@code {
private const int MaxRows = 25;
private IReadOnlyList<CredentialReferenceFinding> _findings = [];
private string? _error;
private bool _busy;
protected override void OnInitialized() => Compose(State.Current);
private void Compose(CredentialReferencePreflightState.Snapshot snapshot)
{
_error = snapshot.Error;
_findings = snapshot.Findings
.Concat(CredentialReferencePreflight.RunningFindings(DriverStatus.GetAll()))
.ToList();
}
private async Task RecheckAsync()
{
var factory = Services.GetService(typeof(IDbContextFactory<OtOpcUaConfigDbContext>)) as IDbContextFactory<OtOpcUaConfigDbContext>;
if (factory is null) return;
_busy = true;
try
{
var findings = await new CredentialReferencePreflight(factory).ScanAsync(CancellationToken.None);
var snapshot = new CredentialReferencePreflightState.Snapshot(findings, DateTime.UtcNow, null);
State.Set(snapshot);
Compose(snapshot);
}
catch (Exception)
{
_error = "The credential-reference preflight could not read the config DB.";
}
finally
{
_busy = false;
}
}
}
@@ -49,13 +49,23 @@
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
@if (RawTagRowDefaults.DictatedDataType(DriverType, row.TagConfig) is { } dictated)
{
@* lmxopcua#830: the driver dictates the type for this address — show it, locked,
rather than offer a choice the commit would replace. *@
<input class="form-control form-control-sm mono" value="@dictated" disabled readonly
title="Set by the address — the driver publishes this type" />
}
else
{
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.AccessLevel"
@@ -246,7 +256,13 @@
if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; }
if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; }
var configError = TagConfigValidator.Validate(DriverType, row.TagConfig);
if (configError is not null) { row.Error = configError; anyClientError = true; }
if (configError is not null) { row.Error = configError; anyClientError = true; continue; }
// The dictated fields are locked to the driver's values (applied on every config edit), so this
// re-application changes nothing the operator could see; the row rule then runs exactly as the
// server's will — a mismatch is SHOWN, never silently rewritten.
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
var rowError = RawTagRowRules.Problem(DriverType, row.AccessLevel, row.DataType, row.TagConfig);
if (rowError is not null) { row.Error = rowError; anyClientError = true; }
}
if (anyClientError)
{
@@ -260,7 +276,6 @@
var failed = 0;
foreach (var row in _rows)
{
RawTagRowDefaults.Apply(DriverType, row.TagConfig, a => row.AccessLevel = a, d => row.DataType = d);
var input = new RawTagInput(
row.Name,
row.DataType,
@@ -46,12 +46,21 @@
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-dtype">Data type</label>
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
@if (RawTagRowDefaults.DictatedDataType(DriverType, _form.TagConfig) is { } dictated)
{
@* lmxopcua#830: locked to what the driver publishes for this address. *@
<input id="raw-tag-dtype" class="form-control form-control-sm mono" value="@dictated" disabled readonly />
<div class="form-text">Set by the address.</div>
}
else
{
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
}
</div>
</div>
<div class="row">
@@ -12,13 +12,15 @@
<label class="form-label" for="hh-address">Address</label>
<input id="hh-address" type="text" class="form-control form-control-sm mono" list="hh-catalog"
value="@_m.Address" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? ""))"
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8" />
placeholder="Status/ExecutionState · PLC:WORD:4 · DATA:/PLC/memory/W/8 · ALARM:*" />
<datalist id="hh-catalog">
@foreach (var e in HeidenhainCatalog.Entries) { <option value="@e.Address"></option> }
</datalist>
<div class="form-text">
A fixed leaf, <span class="mono">PLC:AREA:INDEX</span> (element index into the control's advertised region; AREA one of
@string.Join(", ", HeidenhainAddress.AreaTokens)) or <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver).
@string.Join(", ", HeidenhainAddress.AreaTokens)), <span class="mono">DATA:/path</span> (needs DATA access enabled on the driver),
or an alarm condition <span class="mono">ALARM:*</span> · <span class="mono">ALARM:OTHER</span> ·
<span class="mono">ALARM:CLASS=7[/GROUP=4][/NUMBER=100..199]</span> (enable <em>Native alarm</em> below; acknowledgement is server-side only).
</div>
</div>
<div class="col-md-4">
@@ -28,6 +30,11 @@
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@CatalogShape()" disabled readonly />
<div class="form-text">Fixed by the namespace catalog.</div>
}
else if (_m.IsAlarmAddress)
{
<input id="hh-datatype" type="text" class="form-control form-control-sm mono" value="@HeidenhainTagDefinitionFactory.AlarmConditionDataType" disabled readonly />
<div class="form-text">A Part 9 condition (active / inactive), not a value.</div>
}
else
{
<select id="hh-datatype" class="form-select form-select-sm" value="@_m.DataType"
@@ -0,0 +1,209 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
/// <summary>Where a credential-reference finding came from.</summary>
public enum CredentialFindingSource
{
/// <summary>A stored DriverConfig / DeviceConfig row (what the next deploy will gate).</summary>
Draft,
/// <summary>The latest deployment artifact (what the running drivers were given).</summary>
Deployed,
/// <summary>A running driver that reported resolving it through the #815 warn-only compatibility path.</summary>
Running,
}
/// <summary>One out-of-namespace credential reference (never carries the value).</summary>
/// <param name="Source">Where it was found.</param>
/// <param name="DriverType">The driver type.</param>
/// <param name="DriverName">The driver's display name (or instance id for a running driver).</param>
/// <param name="DriverInstanceId">The driver instance id.</param>
/// <param name="DeviceName">The device, when the reference sits in a DeviceConfig.</param>
/// <param name="Problem">The field and the allowed forms.</param>
public sealed record CredentialReferenceFinding(
CredentialFindingSource Source, string DriverType, string DriverName, string DriverInstanceId, string? DeviceName, string Problem);
/// <summary>
/// Upgrade preflight for lmxopcua#815: lists every stored or deployed DriverConfig / DeviceConfig whose credential
/// fields carry a reference outside the driver's namespace — configs written before the namespaces existed that
/// the deploy gate will now refuse (and that a running driver resolves only in warn-only compatibility mode).
/// Reads the ConfigDb only; resolves nothing, so it never touches the secret store or the environment.
/// </summary>
/// <param name="dbFactory">The config DB.</param>
public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
/// <summary>Scans the draft rows and the latest deployment artifact.</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>The findings.</returns>
public async Task<IReadOnlyList<CredentialReferenceFinding>> ScanAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var drivers = await db.DriverInstances.AsNoTracking().ToListAsync(ct).ConfigureAwait(false);
var ruled = drivers.Where(d => DriverCredentialPolicy.TryGet(d.DriverType, out _)).ToList();
var ids = ruled.Select(d => d.DriverInstanceId).ToHashSet(StringComparer.Ordinal);
var devices = ids.Count == 0
? []
: await db.Devices.AsNoTracking().Where(d => ids.Contains(d.DriverInstanceId)).ToListAsync(ct).ConfigureAwait(false);
var latestBlob = await db.Deployments.AsNoTracking()
.Where(d => d.ArtifactBlob.Length > 0)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => d.ArtifactBlob)
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
var deployed = latestBlob is null ? [] : DeploymentArtifact.ParseDriverInstances(latestBlob);
return Scan(ruled, devices, deployed);
}
/// <summary>The pure scan (tests drive it directly).</summary>
/// <param name="drivers">Stored driver rows.</param>
/// <param name="devices">Stored device rows.</param>
/// <param name="deployed">The latest artifact's driver specs.</param>
/// <returns>The findings.</returns>
public static IReadOnlyList<CredentialReferenceFinding> Scan(
IEnumerable<DriverInstance> drivers, IEnumerable<Device> devices, IEnumerable<DriverInstanceSpec> deployed)
{
var findings = new List<CredentialReferenceFinding>();
var driverList = drivers.ToList();
var byId = driverList.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
foreach (var d in driverList)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig))
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
}
foreach (var dev in devices)
{
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig))
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
}
foreach (var s in deployed)
{
foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig))
findings.Add(new(CredentialFindingSource.Deployed, s.DriverType, s.Name, s.DriverInstanceId, null, p));
}
return findings;
}
/// <summary>
/// Running drivers that reported a warn-only legacy resolution on their diagnostics bag
/// (<see cref="LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix"/>), fleet-wide via the driver-health feed.
/// </summary>
/// <param name="snapshots">The driver-health snapshots.</param>
/// <returns>The findings.</returns>
public static IReadOnlyList<CredentialReferenceFinding> RunningFindings(IEnumerable<Commons.Messages.Drivers.DriverHealthChanged> snapshots) =>
snapshots
.Where(s => s.Diagnostics is not null)
.SelectMany(s => s.Diagnostics!
.Where(kv => kv.Key.StartsWith(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix, StringComparison.Ordinal))
.Select(kv => new CredentialReferenceFinding(
CredentialFindingSource.Running, "", s.DriverInstanceId, s.DriverInstanceId, null,
$"{kv.Key[LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix.Length..]}: {kv.Value}")))
.ToList();
}
/// <summary>The last preflight result, shown by the AdminUI <c>/raw</c> credential banner.</summary>
public sealed class CredentialReferencePreflightState
{
private volatile Snapshot _current = new([], null, null);
/// <summary>An immutable result.</summary>
/// <param name="Findings">The findings.</param>
/// <param name="ScannedAtUtc">When the scan ran (null = not yet).</param>
/// <param name="Error">A scan failure, if any.</param>
public sealed record Snapshot(IReadOnlyList<CredentialReferenceFinding> Findings, DateTime? ScannedAtUtc, string? Error);
/// <summary>The current result.</summary>
public Snapshot Current => _current;
/// <summary>Replaces the result.</summary>
/// <param name="snapshot">The new result.</param>
public void Set(Snapshot snapshot) => _current = snapshot;
}
/// <summary>
/// Runs <see cref="CredentialReferencePreflight"/> once after host start on an admin node and logs one Warning per
/// finding (driver, device, field, allowed forms — never a value). Scheduled after <c>ApplicationStarted</c> plus a
/// short delay, off every Akka starter; it needs only the ConfigDb, never <c>ISecretResolver</c>.
/// </summary>
/// <param name="services">Resolves the ConfigDb factory lazily (absent ⇒ the preflight is skipped).</param>
/// <param name="lifetime">Host lifetime.</param>
/// <param name="state">Where the result is kept for the AdminUI.</param>
/// <param name="logger">Logger.</param>
public sealed class CredentialReferencePreflightService(
IServiceProvider services,
IHostApplicationLifetime lifetime,
CredentialReferencePreflightState state,
ILogger<CredentialReferencePreflightService> logger) : BackgroundService
{
/// <summary>Delay after <c>ApplicationStarted</c> before the scan (tests set it to zero).</summary>
public TimeSpan StartDelay { get; init; } = TimeSpan.FromSeconds(15);
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using (lifetime.ApplicationStarted.Register(() => started.TrySetResult()))
using (stoppingToken.Register(() => started.TrySetCanceled(stoppingToken)))
{
try
{
await started.Task.ConfigureAwait(false);
if (StartDelay > TimeSpan.Zero) await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
}
await RunOnceAsync(stoppingToken).ConfigureAwait(false);
}
/// <summary>One scan + log pass (internal so tests drive it directly).</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>The findings.</returns>
internal async Task<IReadOnlyList<CredentialReferenceFinding>> RunOnceAsync(CancellationToken ct)
{
var dbFactory = services.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
if (dbFactory is null)
{
logger.LogDebug("Credential-reference preflight skipped: no ConfigDb on this node.");
return [];
}
try
{
var findings = await new CredentialReferencePreflight(dbFactory).ScanAsync(ct).ConfigureAwait(false);
state.Set(new(findings, DateTime.UtcNow, null));
foreach (var f in findings)
{
logger.LogWarning(
"Credential-reference preflight (lmxopcua#815): {Source} {DriverType} driver '{DriverName}'{Device}: {Problem} "
+ "The deploy gate refuses this; a running driver resolves it only in deprecated warn-only mode. "
+ "See docs/security.md \"Credential references are namespaced per driver\".",
f.Source, f.DriverType, f.DriverName, f.DeviceName is null ? "" : $" device '{f.DeviceName}'", f.Problem);
}
if (findings.Count == 0)
logger.LogInformation("Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced.");
return findings;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Credential-reference preflight (lmxopcua#815) could not read the config DB.");
state.Set(new([], DateTime.UtcNow, "The credential-reference preflight could not read the config DB."));
return [];
}
}
}
@@ -72,6 +72,10 @@ public static class EndpointRouteBuilderExtensions
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
// lmxopcua#815 upgrade preflight: one ConfigDb scan after start (no secret resolution), WARN per
// out-of-namespace credential reference, shown by the /raw credential banner.
services.AddSingleton<Deployments.CredentialReferencePreflightState>();
services.AddHostedService<Deployments.CredentialReferencePreflightService>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>();
@@ -1,5 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -10,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// block Save while the form is invalid.
/// </summary>
/// <remarks>
/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain
/// namespace (<see cref="HeidenhainOptionsParser.DescribeCredentialProblems"/>) — otherwise a literal password
/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it.
/// Today it carries one rule, for every driver that resolves credential references
/// (<see cref="DriverCredentialPolicy"/> — Heidenhain, Galaxy, OpcUaClient): a credential field must be blank,
/// an acceptable literal for that field, or a reference inside the driver's namespace (lmxopcua#803/#815) —
/// otherwise an exfiltration-shaped reference (or a Heidenhain literal password) would be persisted in a draft
/// (and replicated) even though the deploy gate and every resolver would later refuse it.
/// </remarks>
public static class DriverConfigSaveGate
{
@@ -22,8 +23,7 @@ public static class DriverConfigSaveGate
/// <returns>The refusal (never containing a credential value), or null.</returns>
public static string? Validate(string? driverType, string? configJson)
{
if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null;
var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson);
var problems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
return problems.Count == 0 ? null : string.Join(" ", problems);
}
}
@@ -246,6 +246,13 @@ public static class RawTagCsvMapper
return Invalid(rowNumber, cells, $"TagConfig: {configError}");
}
// The driver's Tag-ROW rules (access level / data type — lmxopcua#830): the SAME authority the commit
// (RawTreeService.ImportTagsAsync) applies, so a row the commit would refuse is never previewed as OK.
if (RawTagRowRules.Problem(driverType, access, dataType, json) is { } rowError)
{
return Invalid(rowNumber, cells, rowError);
}
var importRow = new RawTagImportRow(groupPath, new RawTagInput(name, dataType, access, writeIdempotent, pollGroupId, json));
return new RawCsvReviewRow
@@ -1,22 +1,27 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803): for a Heidenhain tag the
/// access level is always Read and <c>Tag.DataType</c> follows the address (catalog type, or the binding's
/// declared type). Used by the tag modal and the manual-entry grid so the operator never has to pick values the
/// server (<c>RawTreeService</c>) and the deploy gate would refuse.
/// Authoring-time defaults for a raw tag ROW that a driver dictates (lmxopcua#803/#830): for a Heidenhain tag the
/// access level is always Read and <c>Tag.DataType</c> follows the address (catalog type, the binding's declared
/// type, or Boolean for an alarm condition). Used by the tag modal and the manual-entry grid, which LOCK both
/// fields while they are dictated — so the operator can neither pick a value the save path would refuse nor have
/// one silently replaced on commit. The rules themselves live in <see cref="RawTagRowRules"/>.
/// </summary>
public static class RawTagRowDefaults
{
/// <summary>True for a driver whose tags can never be writable.</summary>
/// <param name="driverType">The driver type.</param>
/// <returns>Whether the access level is fixed at Read.</returns>
public static bool IsReadOnlyDriver(string? driverType) =>
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase);
public static bool IsReadOnlyDriver(string? driverType) => RawTagRowRules.IsReadOnlyDriver(driverType);
/// <summary>The data type the driver dictates for this TagConfig (lock the field to it), or null.</summary>
/// <param name="driverType">The driver type.</param>
/// <param name="tagConfig">The row's TagConfig.</param>
/// <returns>The dictated type, or null when the operator chooses.</returns>
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
RawTagRowRules.DictatedDataType(driverType, tagConfig);
/// <summary>Applies the driver-dictated access level and data type for a row, when the driver dictates them.</summary>
/// <param name="driverType">The driver type.</param>
@@ -25,8 +30,7 @@ public static class RawTagRowDefaults
/// <param name="setDataType">Sets the row's <c>Tag.DataType</c>.</param>
public static void Apply(string? driverType, string? tagConfig, Action<TagAccessLevel> setAccess, Action<string> setDataType)
{
if (!IsReadOnlyDriver(driverType)) return;
setAccess(TagAccessLevel.Read);
if (HeidenhainTagRules.ExpectedTagDataType(tagConfig) is { } dataType) setDataType(dataType);
if (IsReadOnlyDriver(driverType)) setAccess(TagAccessLevel.Read);
if (DictatedDataType(driverType, tagConfig) is { } dataType) setDataType(dataType);
}
}
@@ -0,0 +1,66 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// The ONE authority on driver-specific Tag-ROW rules — rules about the row's access level and
/// <c>Tag.DataType</c>, beyond its TagConfig (lmxopcua#830). Every authoring path asks it: the server save path
/// (<see cref="RawTreeService"/> create / update / import), the CSV import's staged review grid
/// (<see cref="RawTagCsvMapper"/>), and the manual-entry grid + tag modal (through <see cref="RawTagRowDefaults"/>,
/// which locks the dictated fields). A preview that skipped it used to show "OK" for a row the commit then refused.
/// </summary>
/// <remarks>
/// A driver with row rules registers its functions in <see cref="Rules"/>; the call sites never name a driver.
/// The deploy gate applies the same driver functions (e.g. <c>HeidenhainDeployChecks</c> →
/// <see cref="HeidenhainTagRules"/>), so authoring and deploy cannot disagree.
/// </remarks>
public static class RawTagRowRules
{
/// <summary>One driver's row rule: (readWrite, tagDataType, tagConfig) → refusal or null.</summary>
/// <param name="readWrite">Whether the row is ReadWrite.</param>
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
/// <param name="tagConfig">The row's TagConfig.</param>
public delegate string? RowRule(bool readWrite, string? tagDataType, string? tagConfig);
/// <summary>One driver's dictated data type for a TagConfig, or null when the operator chooses.</summary>
/// <param name="tagConfig">The row's TagConfig.</param>
public delegate string? DataTypeRule(string? tagConfig);
private static readonly Dictionary<string, (RowRule Row, DataTypeRule DataType, bool ReadOnly)> Rules =
new(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Heidenhain] = (HeidenhainTagRules.DescribeRowProblem, HeidenhainTagRules.ExpectedTagDataType, true),
};
/// <summary>The driver types that carry row rules (test seam / documentation).</summary>
public static IReadOnlyCollection<string> DriverTypesWithRules => Rules.Keys;
/// <summary>Why a row may not be saved for this driver, or null when it may.</summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="accessLevel">The row's access level.</param>
/// <param name="tagDataType">The row's <c>Tag.DataType</c>.</param>
/// <param name="tagConfig">The row's (normalized) TagConfig.</param>
/// <returns>The refusal, or null.</returns>
public static string? Problem(string? driverType, TagAccessLevel accessLevel, string? tagDataType, string? tagConfig) =>
driverType is not null && Rules.TryGetValue(driverType, out var r)
? r.Row(accessLevel == TagAccessLevel.ReadWrite, tagDataType, tagConfig)
: null;
/// <summary>True for a driver whose tags can never be writable (the access level is fixed at Read).</summary>
/// <param name="driverType">The driver type.</param>
/// <returns>Whether the access level is fixed.</returns>
public static bool IsReadOnlyDriver(string? driverType) =>
driverType is not null && Rules.TryGetValue(driverType, out var r) && r.ReadOnly;
/// <summary>
/// The <c>Tag.DataType</c> the driver dictates for this TagConfig (so a form must lock the field to it), or
/// null when the operator chooses (any driver without a rule, or a TagConfig that does not resolve yet).
/// </summary>
/// <param name="driverType">The driver type.</param>
/// <param name="tagConfig">The TagConfig.</param>
/// <returns>The dictated type name, or null.</returns>
public static string? DictatedDataType(string? driverType, string? tagConfig) =>
driverType is not null && Rules.TryGetValue(driverType, out var r) ? r.DataType(tagConfig) : null;
}
@@ -1272,14 +1272,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.FirstOrDefaultAsync(ct);
/// <summary>
/// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only
/// and carry the data type the driver publishes — the same rule the deploy gate applies.
/// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803/#830) — <see cref="RawTagRowRules"/>, the
/// same authority the CSV review grid previews with, and the same driver rule the deploy gate applies.
/// </summary>
private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) =>
string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)
? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem(
input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig)
: null;
RawTagRowRules.Problem(driverType, input.AccessLevel, input.DataType, tagConfig);
/// <summary>
/// The driver-typed <c>TagConfig</c> gate, applied on the SERVER for every tag-writing path —
@@ -32,6 +32,21 @@ public sealed class HeidenhainTagConfigModel
/// <summary>True when <see cref="Address"/> is a fixed catalog leaf.</summary>
public bool IsCatalogAddress => HeidenhainCatalog.TryGet(Address.Trim(), out _);
/// <summary>
/// True when <see cref="Address"/> is an <c>ALARM:</c> condition selector (lmxopcua#831): its data type is fixed
/// (<see cref="HeidenhainTagDefinitionFactory.AlarmConditionDataType"/>) and it needs the tag's native alarm enabled.
/// </summary>
public bool IsAlarmAddress => Address.Trim().StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal);
/// <summary>True when the data type is dictated by the address (catalog leaf or alarm condition), not chosen.</summary>
public bool HasFixedShape => IsCatalogAddress || IsAlarmAddress;
/// <summary>The Part 9 subtype seeded for a freshly picked <c>ALARM:</c> selector.</summary>
public const string DefaultAlarmType = "AlarmCondition";
/// <summary>The severity (1..1000) seeded for a freshly picked <c>ALARM:</c> selector.</summary>
public const int DefaultAlarmSeverity = 700;
public static HeidenhainTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -39,6 +54,7 @@ public sealed class HeidenhainTagConfigModel
var typeText = TagConfigJson.GetString(o, "dataType");
DriverDataType type;
if (HeidenhainCatalog.TryGet(address.Trim(), out var entry)) type = entry.DataType;
else if (address.Trim().StartsWith(HeidenhainAlarmSelector.Prefix, StringComparison.Ordinal)) type = HeidenhainTagDefinitionFactory.AlarmConditionDataType;
else if (typeText is not null && HeidenhainTagDefinitionFactory.TryParseDataTypeName(typeText, out var parsed)) type = parsed;
else type = DriverDataType.Int32;
return new HeidenhainTagConfigModel { Address = address, DataType = type, _bag = o, _source = json ?? "" };
@@ -54,12 +70,28 @@ public sealed class HeidenhainTagConfigModel
TagConfigJson.Set(_bag, "dataType", entry.DataType);
TagConfigJson.Set(_bag, "isArray", entry.IsArray ? true : null);
}
else if (IsAlarmAddress)
{
TagConfigJson.Set(_bag, "dataType", HeidenhainTagDefinitionFactory.AlarmConditionDataType);
TagConfigJson.Set(_bag, "isArray", null);
}
else
{
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "isArray", null);
}
// An ALARM: selector and the cross-driver alarm object only work together (the driver refuses either alone),
// so an edit keeps them paired: picking a selector seeds a ready-to-author condition (the Galaxy picker
// convenience — every authoring path, incl. the manual-entry grid that has no alarm section, can then save it),
// and leaving the selector drops the object that could no longer be raised. An authored object is never
// overwritten.
var hasAlarm = _bag.TryGetPropertyValue("alarm", out var alarmNode) && alarmNode is JsonObject;
if (IsAlarmAddress && !hasAlarm)
_bag["alarm"] = new JsonObject { ["alarmType"] = DefaultAlarmType, ["severity"] = DefaultAlarmSeverity };
else if (!IsAlarmAddress && hasAlarm)
_bag.Remove("alarm");
return _source = TagConfigJson.Serialize(_bag);
}
@@ -132,7 +132,7 @@ public sealed class NativeAlarmModel
{
Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
Core.Abstractions.DriverTypeNames.Galaxy,
Core.Abstractions.DriverTypeNames.Galaxy, Core.Abstractions.DriverTypeNames.Heidenhain,
};
/// <summary>True when the driver type can emit native alarms.</summary>
@@ -585,6 +585,20 @@ public sealed class AdminOperationsActor : ReceiveActor
return;
}
// lmxopcua#815: refuse a credential reference outside the driver's namespace BEFORE any probe runs — the
// probe would otherwise be the seam that resolves it and sends it to the author-chosen endpoint. Nothing
// is read from the secret store or the environment on this path.
var credentialProblems = DriverCredentialPolicy.DescribeProblems(msg.DriverType, msg.ConfigJson);
if (credentialProblems.Count > 0)
{
replyTo.Tell(new TestDriverConnectResult(
false,
"Test Connect refused: " + string.Join(" ", credentialProblems),
null,
msg.CorrelationId));
return;
}
Task.Run(() => ProbeAsync(probe, msg)).PipeTo(replyTo);
}
@@ -39,7 +39,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// the legacy <c>EquipmentNodeWalker</c> server-side integration was retired in favour of the
/// (composer → applier → sink → node-manager) chain.
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
// INodeManager3 is RE-IMPLEMENTED (Gitea #826): the SDK's per-folder event delivery (MonitoredNode2) asks
// INodeManager3.ValidateEventRolePermissions for every event and item, and the base implementation is not virtual, so
// re-implementing the interface is the only way to put the per-event ACL decision on that path. Every other member
// maps to the inherited implementation exactly as before.
public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
{
/// <summary>v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are
/// registered with the SDK so nodes can be minted in either. <see cref="V3NodeIds.RawNamespaceUri"/> is
@@ -57,6 +61,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
/// <summary>
/// UNS variables PROVEN to be UNS reference variables — the source of a forward <c>Organizes</c> edge into the raw
/// namespace (<see cref="AddReference"/>), which only the applier's reference pass wires. Gitea #826: the
/// reference-vs-folder-owned decision in <see cref="ResolveUnsAclSubject"/> no longer rests on the id rule alone
/// (an Area named exactly like its equipment's id made that equipment's references look folder-owned). Holds the
/// instance, so a stale entry for a replaced node never matches; cleared with <see cref="_variables"/>.
/// </summary>
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _unsReferenceVariables = new();
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
@@ -567,6 +580,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// walking its own dead item map on each deploy.
previous.Changed -= OnAuthorityChanged;
next.Changed += OnAuthorityChanged;
InvalidateFolderEventVerdicts();
}
}
@@ -640,7 +654,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// there is no second wiring that could silently not run (an unattached resolver splits opaque ids,
// which only ever DENIES narrow grants, so a miss would read as working enforcement).
if (value is not null) value.UnsSubjectResolver = ResolveUnsAclSubject;
var wasEnforced = AclEnforced;
_authorizer = value;
// Across an on/off transition the cached verdicts were decided under the other mode.
InvalidateFolderEventVerdicts(force: wasEnforced || AclEnforced);
}
}
@@ -3604,6 +3621,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
// Gitea #826: a UNS variable organizing a raw node IS a UNS reference variable — recorded so the ACL
// placement can say so explicitly instead of inferring it from the id's first segment.
if (sourceRealm == AddressSpaceRealm.Uns && targetRealm == AddressSpaceRealm.Raw
&& refType == ReferenceTypeIds.Organizes
&& source is BaseDataVariableState variable
&& _variables.TryGetValue(sourceKey, out var live) && ReferenceEquals(live, variable))
{
_unsReferenceVariables[sourceKey] = variable;
}
}
}
@@ -3851,6 +3878,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId);
_variables.Clear();
_unsReferenceVariables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
@@ -3898,6 +3926,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
public void CompleteAddressSpaceApply()
{
// Gitea #826: the apply moved conditions, wiring and folders, and the SDK memoizes each folder item's per-event
// verdict per (item, event type, condition) until told otherwise — forget them before the restored reports
// below go out, so the next event on every folder item is decided against the address space as applied.
InvalidateFolderEventVerdicts();
int reported = 0, discarded;
lock (Lock)
{
@@ -3938,6 +3970,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(key, out var variable)) return false;
_unsReferenceVariables.TryRemove(key, out _);
// Conservatively invalidate every outstanding history cursor on topology removal.
Interlocked.Increment(ref _historyContinuationEpoch);
UnindexChild(key);
@@ -4105,6 +4138,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return;
}
_unsReferenceVariables.TryRemove(key, out _);
if (_variables.TryRemove(key, out var v))
{
UnindexChild(key);
@@ -4301,6 +4335,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (s=Area/Line/Equipment/EffectiveName): authorized at its split path, but only while that path is where the
// current generation places the equipment it hangs under (review of #808 — a moved or renamed equipment's
// variables keep their OLD-name ids until re-pointed, and the old names' grants must not keep them).
// Gitea #826: a variable the reference pass wired to its raw node is a reference variable whatever its id
// spells — so an Area named exactly like its equipment's id no longer turns that equipment's references
// folder-owned (which only ever narrowed: it lost their Tag-grant match). Untracked nodes keep the id rule.
if (node is BaseDataVariableState v
&& _unsReferenceVariables.TryGetValue(key, out var reference) && ReferenceEquals(reference, v))
{
return UnsAclSubject.ReferenceVariable(chain);
}
return firstSegment.SequenceEqual(owning.NodeId.IdentifierAsString)
? UnsAclSubject.Folder(chain, isFolderItself: ReferenceEquals(node, owning))
: UnsAclSubject.ReferenceVariable(chain);
@@ -5478,13 +5521,236 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (_alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
&& ReferenceEquals(wiring.Alarm, condition))
{
scopes.AddRange(wiring.Folders.Select(f => f.NodeId));
// Gitea #826: only a folder THIS generation still projects the alarm's raw tag into. A dropped
// reference leaves the wiring in place until the rebuild lands (or indefinitely after a failed apply),
// and that folder's grant must stop admitting the alarm the moment the generation drops it.
var authority = DataPlaneAuthority.Current;
foreach (var folder in wiring.Folders)
{
if (IsWiredFolderCurrent(authority, condition, folder)) scopes.Add(folder.NodeId);
}
}
}
return scopes;
}
/// <summary>
/// Whether <paramref name="folder"/>, wired as an extra notifier of the native <paramref name="condition"/>, is
/// one the current generation still projects the condition's raw tag into (Gitea #826).
/// </summary>
/// <param name="authority">The current generation.</param>
/// <param name="condition">A native condition (<c>ConditionId</c> = RawPath).</param>
/// <param name="folder">A folder wired for it.</param>
/// <returns><see langword="true"/> when a reference of the generation connects them.</returns>
/// <remarks>Only UNS equipment folders are ever wired; anything else is not a current wiring (fail closed).</remarks>
private bool IsWiredFolderCurrent(DataPlaneAuthoritySnapshot authority, NodeState condition, FolderState folder) =>
folder.NodeId.NamespaceIndex == NamespaceIndexForRealm(AddressSpaceRealm.Uns)
&& folder.NodeId.TryGetValue(out string equipmentId)
&& condition.NodeId.TryGetValue(out string rawPath)
&& authority.IsTagReferencedBy(rawPath, equipmentId);
/// <summary>
/// The per-event decision for an event item on one of this manager's own notifiers (an equipment folder, a raw
/// device folder) — the SDK fan-out path the Server-object filtering (#586) does not reach (Gitea #826).
/// </summary>
/// <param name="item">The event item; its manager handle names the notifier it is attached to.</param>
/// <param name="e">The event as the SDK delivers it (a condition snapshot, or a cloned audit event).</param>
/// <returns><see langword="true"/> when the item's session may receive it through that notifier.</returns>
/// <remarks>
/// <para>
/// Three conditions, all against the CURRENT state: a live condition holds the event's condition NodeId;
/// the notifier is still a legitimate notifier of THAT live instance — the condition itself, a node it hangs
/// beneath, or a wired folder the current generation still projects its raw tag into; and the condition is
/// visible to the session under exactly the Server-object rule (<see cref="IsConditionVisibleTo"/> over
/// <see cref="ConditionVisibilityScopes"/>), so a moved scripted alarm is withheld from its old folder just
/// as it is from the Server object.
/// </para>
/// <para>
/// An event that names no live condition of this manager is withheld (fail closed): only condition events
/// and the condition's own method audits reach these notifiers.
/// </para>
/// <para>
/// ⚠️ <b>Decided on the live instance, never on the event's own (review of #826).</b> The SDK caches the
/// verdict per <c>(item, event type, SourceNode)</c> — no instance identity — so a verdict must be a
/// function of the condition's NodeId and the current state alone. Denying an event because it came from a
/// REPLACED instance (a surgical remove + re-materialise under the same NodeId, the old instance's last
/// event still in the SDK's queue) cached a deny the new instance then inherited until the next
/// invalidation. A late event from a replaced instance is therefore delivered exactly when the live one's
/// would be.
/// </para>
/// <para>
/// Runs on the SDK's per-node delivery task, where the inherited role-permission check already takes
/// <c>Lock</c> (via <c>GetNodeMetadata</c>); the scope resolution here takes it the same way.
/// </para>
/// </remarks>
private bool IsNotifierEventVisibleTo(IEventMonitoredItem item, IFilterTarget e)
{
if (item.ManagerHandle is not NodeHandle { Node: { } notifier }) return false;
if (EventConditionOf(e) is not { } condition) return false;
if (!_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live)) return false;
if (!IsLiveNotifierOf(live, notifier)) return false;
return IsConditionVisibleTo(item, ConditionVisibilityScopes(live));
}
/// <summary>The condition an event delivered through a notifier is about, or null.</summary>
/// <param name="e">The event: a condition's snapshot, the condition itself, or an event whose SourceNode names one.</param>
/// <returns>The condition state, or null.</returns>
private ConditionState? EventConditionOf(IFilterTarget e)
{
var node = ConditionOf(e);
if (node is ConditionState condition) return condition;
// A condition's Part 9 method audit (cloned by the SDK on its way through a notifier): its SourceNode is the
// condition.
return node is BaseEventState { SourceNode.Value: { IsNull: false } source }
&& OwnsNamespace(source)
&& _alarmConditions.TryGetValue(MapKey(source), out var named)
? named
: null;
}
/// <summary>Whether <paramref name="notifier"/> is, right now, a node the condition's events legitimately bubble
/// through (Gitea #826).</summary>
/// <param name="condition">The live condition.</param>
/// <param name="notifier">The node the event item is attached to.</param>
/// <returns><see langword="true"/> for the condition itself, a node above it, or a wired folder the current
/// generation still projects it into.</returns>
private bool IsLiveNotifierOf(AlarmConditionState condition, NodeState notifier)
{
if (ReferenceEquals(notifier, condition)) return true;
lock (Lock)
{
for (var ancestor = (condition as BaseInstanceState)?.Parent;
ancestor is not null;
ancestor = (ancestor as BaseInstanceState)?.Parent)
{
if (ReferenceEquals(ancestor, notifier)) return true;
}
return notifier is FolderState folder
&& _alarmNotifierWiring.TryGetValue(MapKey(condition.NodeId), out var wiring)
&& ReferenceEquals(wiring.Alarm, condition)
&& wiring.Folders.Contains(folder)
&& IsWiredFolderCurrent(DataPlaneAuthority.Current, condition, folder);
}
}
/// <summary>
/// Validates whether an event item may receive an event: the SDK's role-permission check, and — with enforcement
/// on, for an item on one of this manager's own nodes — <see cref="IsNotifierEventVisibleTo"/> (Gitea #826).
/// </summary>
/// <param name="monitoredItem">The event item.</param>
/// <param name="filterTarget">The event.</param>
/// <returns>Good to deliver; otherwise the veto.</returns>
/// <remarks>
/// <para>
/// The SDK's <c>MonitoredNode2</c> asks this, per event and per item, before it queues an event to an item
/// attached to one of this manager's notifiers. The Server-object path (#586) and ConditionRefresh decide
/// the same question themselves, so a folder's subscribers stop receiving a moved alarm (or a native alarm
/// whose reference was dropped) the moment the generation says so, not when the rebuild lands.
/// </para>
/// <para>
/// ⚠️ The SDK MEMOIZES the verdict per <c>(item, event type, SourceNode)</c> until the monitored node's
/// permission generation moves, so <see cref="InvalidateFolderEventVerdicts"/> must run whenever the inputs
/// change: a new authority generation or authorizer, the revalidation tick, and the end of every apply. A
/// condition's SourceNode is the condition itself, so a verdict is never shared between two conditions.
/// </para>
/// </remarks>
ServiceResult INodeManager3.ValidateEventRolePermissions(IEventMonitoredItem monitoredItem, IFilterTarget filterTarget)
{
var result = ValidateEventRolePermissions(monitoredItem, filterTarget);
if (ServiceResult.IsBad(result) || !AclEnforced || !OwnsNamespace(monitoredItem.NodeId)) return result;
return IsNotifierEventVisibleTo(monitoredItem, filterTarget) ? result : FolderEventDenied;
}
/// <summary>The verdict a withheld folder event carries back to the SDK (never reaches the client).</summary>
private static readonly ServiceResult FolderEventDenied = new(StatusCodes.BadUserAccessDenied);
/// <summary>
/// Drops every per-event verdict the SDK has memoized for event items on this manager's notifiers (Gitea #826),
/// so the next event on each is decided afresh by <see cref="IsNotifierEventVisibleTo"/>.
/// </summary>
/// <param name="force">Run even while enforcement is off — the authorizer setter's on/off transition, where the
/// cached verdicts were decided under the other mode.</param>
/// <remarks>
/// <para>
/// <b>Node-wide, session-agnostic.</b> Each notifier's cache is cleared whole through the SDK's own private
/// <c>MonitoredNode2.InvalidatePermissionCaches</c> (what the SDK runs when namespace default permissions
/// change, and on <c>Rebind</c>): it advances the node's permission generation — a verdict computed
/// concurrently under the old one is recomputed, never stored — and clears the cache in one O(cache) pass.
/// The public <c>InvalidatePermissionCacheForSession</c> is NOT sufficient (review of #826): it reaches only
/// items whose <c>Session</c> is set, and an ORPHANED subscription's items (session closed without deleting
/// its subscriptions) have none — they kept a stale allow through a deploy, queued the moved alarm's events
/// and delivered them on <c>TransferSubscriptions</c>. It also rescanned the whole cache once per item
/// (items² × conditions per folder).
/// </para>
/// <para>
/// The private method is resolved once by reflection against the pinned SDK and pinned by
/// <c>FolderEventVerdictInvalidationGuardTests</c>. Should a future SDK drop it, this falls back to the
/// supported route: the per-session drop for items with a session, and a detach + re-attach of each
/// session-less item on its node (<c>Remove</c> drops that item's entries), which can miss an event racing
/// the swap — the reason it is only the fallback.
/// </para>
/// <para>
/// Skipped while enforcement is off (<paramref name="force"/> aside): the verdicts are then the SDK's own
/// role check, which nothing here changes. The node list is copied under <c>Lock</c>; the invalidation
/// takes no lock of ours.
/// </para>
/// </remarks>
private void InvalidateFolderEventVerdicts(bool force = false)
{
if (_root is null || (!force && !AclEnforced)) return;
List<MonitoredNode2> nodes;
lock (Lock) nodes = MonitoredNodes.Values.Where(n => n.EventMonitoredItems is { IsEmpty: false }).ToList();
foreach (var node in nodes)
{
Interlocked.Increment(ref _folderEventVerdictInvalidations);
if (InvalidateNodePermissionCaches is { } invalidate)
{
invalidate(node);
continue;
}
var sessions = new HashSet<NodeId>();
var sessionless = new List<IEventMonitoredItem>();
foreach (var item in node.EventMonitoredItems.Values)
{
if (item is null) continue;
if (item.Session?.Id is { IsNull: false } id) sessions.Add(id);
else sessionless.Add(item);
}
foreach (var session in sessions) node.InvalidatePermissionCacheForSession(session);
foreach (var item in sessionless)
{
node.Remove(item);
node.Add(item);
}
}
}
/// <summary>Notifier caches <see cref="InvalidateFolderEventVerdicts"/> has invalidated since start. Test
/// observability (it must not run while enforcement is off).</summary>
internal long FolderEventVerdictInvalidations => Interlocked.Read(ref _folderEventVerdictInvalidations);
private long _folderEventVerdictInvalidations;
/// <summary>
/// The SDK's private node-wide cache invalidation (<c>MonitoredNode2.InvalidatePermissionCaches</c>), or null
/// when the SDK no longer has it — see <see cref="InvalidateFolderEventVerdicts"/>.
/// </summary>
internal static readonly Action<MonitoredNode2>? InvalidateNodePermissionCaches =
typeof(MonitoredNode2).GetMethod(
"InvalidatePermissionCaches",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
Type.EmptyTypes) is { ReturnType: var returns } method && returns == typeof(void)
? method.CreateDelegate<Action<MonitoredNode2>>()
: null;
/// <summary>Whether <paramref name="item"/>'s session may see a condition with the given scopes.</summary>
/// <param name="item">The Server-object event item.</param>
/// <param name="scopes">From <see cref="ConditionVisibilityScopes"/>.</param>
@@ -5530,10 +5796,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
(item.NodeId == ObjectIds.Server ? serverItems : direct).Add(item);
}
if (direct.Count > 0) base.ConditionRefresh(context, direct);
var systemContext = SystemContext.Copy(context);
if (direct.Count > 0) RefreshDirectItems(systemContext, direct);
if (serverItems.Count == 0) return ServiceResult.Good;
var systemContext = SystemContext.Copy(context);
List<NodeState> notifiers;
lock (Lock) notifiers = RootNotifiers?.ToList() ?? [];
@@ -5562,6 +5828,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return ServiceResult.Good;
}
/// <summary>
/// ConditionRefresh for event items on this manager's own notifiers, under enforcement (Gitea #826): the SDK
/// base's loop exactly — an item no longer attached (stood down by revalidation, or tombstoned) is not
/// refreshed — plus the per-event decision live delivery makes (<see cref="IsNotifierEventVisibleTo"/>), so a
/// refresh cannot replay to a folder's subscriber an alarm that folder's live events withhold.
/// </summary>
/// <param name="systemContext">The refresh's system context.</param>
/// <param name="items">The direct (non-Server-object) items being refreshed.</param>
private void RefreshDirectItems(ServerSystemContext systemContext, List<IEventMonitoredItem> items)
{
foreach (var item in items)
{
NodeState? notifier;
lock (Lock)
{
if (!MonitoredItems.ContainsKey(item.Id)) continue;
notifier = (item.ManagerHandle as NodeHandle)?.Node;
}
if (notifier is null) continue;
var events = new List<IFilterTarget>();
notifier.ConditionRefresh(systemContext, events, true);
foreach (var e in events)
{
if (!IsNotifierEventVisibleTo(item, e)) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
item.QueueEvent(e);
}
}
}
/// <summary>The condition a refresh event describes, or null when it names none (withheld: fail closed).</summary>
/// <param name="e">A refresh event.</param>
/// <returns>The condition node.</returns>
@@ -5663,7 +5961,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </remarks>
public void RevalidateMonitoredItems()
{
if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return;
if (!AclEnforced) return;
// Gitea #826: both triggers (a deploy, the clock) also reach the per-event decision on folder event items, whose
// verdicts the SDK memoizes — a deploy that moves an alarm must not keep serving its old folder's allow.
InvalidateFolderEventVerdicts();
if (_liveItems.IsEmpty && _eventItems.IsEmpty) return;
var now = UtcNow;
var stoodDown = 0;
@@ -93,6 +93,16 @@ public sealed record DataPlaneAuthorityProjection(
public IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes { get; init; } =
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// <c>RawPath → EquipmentIds</c> from the artifact's <c>UnsTagReferences</c> (Gitea #826): which equipment
/// folders this generation projects each ALARM-BEARING raw tag (<c>TagConfig.alarm</c>) into — the folders a native alarm on that tag may be
/// delivered through. RawPaths come from the SAME <see cref="AclScopePathResolver"/> as
/// <see cref="ScopePaths"/>, so they equal the native conditions' <c>ConditionId</c>s by construction. Empty
/// when the artifact carries no grants.
/// </summary>
public IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment { get; init; } =
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
/// <summary>The projection of an absent or unparseable artifact: no cluster, no keys, no grants.</summary>
public static readonly DataPlaneAuthorityProjection Empty =
new(null, [], [], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
@@ -938,7 +948,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
grants.Add(acl);
}
var (scopePaths, equipmentScopePaths, unsFolderScopes) = BuildAclScopePaths(root, grants);
var (scopePaths, equipmentScopePaths, unsFolderScopes, tagReferencingEquipment) = BuildAclScopePaths(root, grants);
// Every scripted alarm's owner, from the rows the address space is composed from. Read regardless of
// the grant count: it is a topology fact, cheap, and an absent map only ever denies.
var scriptedAlarmOwners = new Dictionary<string, string>(StringComparer.Ordinal);
@@ -955,6 +965,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
EquipmentScopePaths = equipmentScopePaths,
ScriptedAlarmOwners = scriptedAlarmOwners,
UnsFolderScopes = unsFolderScopes,
TagReferencingEquipment = tagReferencingEquipment,
};
}
@@ -964,10 +975,12 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
/// path and the RawPath that forms a node's NodeId cannot drift apart.
/// </summary>
/// <returns>The grant scope paths, plus every equipment's own UNS path (the scripted-alarm owner lookup,
/// Gitea #797) and every UNS folder's path + id chain (Gitea #808) from the same resolver.</returns>
/// Gitea #797), every UNS folder's path + id chain (Gitea #808) and every referenced raw tag's referencing
/// equipment (Gitea #826) from the same resolver.</returns>
private static (IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> ScopePaths,
IReadOnlyDictionary<string, NodeAclPath> EquipmentScopePaths,
IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes) BuildAclScopePaths(
IReadOnlyDictionary<string, UnsFolderScope> UnsFolderScopes,
IReadOnlyDictionary<string, IReadOnlySet<string>> TagReferencingEquipment) BuildAclScopePaths(
JsonElement root,
IReadOnlyList<NodeAcl> grants)
{
@@ -975,7 +988,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
{
return (new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>(),
new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase));
new Dictionary<string, UnsFolderScope>(StringComparer.OrdinalIgnoreCase),
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal));
}
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
@@ -1013,12 +1027,17 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
}
var tags = new Dictionary<string, (string DeviceId, string? TagGroupId, string Name)>(StringComparer.Ordinal);
// TagConfig kept unparsed: only a REFERENCED tag's is parsed below (Gitea #826 — alarm-bearing ones only).
var tagConfigs = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Tags"))
{
var id = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
if (id is null || deviceId is null) continue;
tags[id] = (deviceId, ReadNullableString(el, "TagGroupId"), ReadString(el, "Name") ?? id);
tagConfigs[id] = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString()
: null;
}
var areas = new Dictionary<string, string>(StringComparer.Ordinal);
@@ -1077,7 +1096,29 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
if (resolver.ResolveOne(NodeAclScopeKind.Equipment, equipmentId) is [var path]) equipmentPaths[equipmentId] = path;
}
return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment));
// Which equipment folders each ALARM-BEARING raw tag is projected into (Gitea #826) — the folders a native alarm
// on that tag may legitimately be delivered through; only native conditions are ever looked up, so plain value
// tags are left out. Keyed on the RawPath the SAME resolver builds, which is the native condition's ConditionId.
// Alarm-bearing is decided by the SAME TagConfigIntent the composer materialises native conditions from.
var alarmBearing = new Dictionary<string, bool>(StringComparer.Ordinal);
var referencing = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var reference in references)
{
if (!alarmBearing.TryGetValue(reference.TagId, out var isAlarm))
{
isAlarm = TagConfigIntent.Parse(tagConfigs.GetValueOrDefault(reference.TagId)).Alarm is not null;
alarmBearing[reference.TagId] = isAlarm;
}
if (!isAlarm) continue;
if (resolver.ResolveTagRawPath(reference.TagId) is not { } rawPath) continue;
if (!referencing.TryGetValue(rawPath, out var set))
referencing[rawPath] = set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
set.Add(reference.EquipmentId);
}
return (resolver.Resolve(grants), equipmentPaths, BuildUnsFolderScopes(resolver, areas, lines, equipment),
referencing.ToDictionary(kv => kv.Key, kv => (IReadOnlySet<string>)kv.Value, StringComparer.Ordinal));
}
/// <summary>
@@ -1426,7 +1426,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
var snapshot = _dataPlaneAuthority.Apply(
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths,
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes);
projection.EquipmentScopePaths, projection.ScriptedAlarmOwners, projection.UnsFolderScopes,
projection.TagReferencingEquipment);
_log.Info(
"OpcUaPublish: data-plane authority generation {Generation} installed (cluster={Cluster}, revision={Revision}, keys={Keys}, grants={Grants}, resolvedScopes={ResolvedScopes})",