docs(security,core): correct stale write-outcome doc + note benign DraftSnapshot/LeaderChanged residue (stillpending §9/§3)
This commit is contained in:
+17
-1
@@ -327,9 +327,25 @@ The rule is intentionally scoped to async surfaces — pure in-memory accessors
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Write-Outcome Self-Correction
|
||||||
|
|
||||||
|
When an OPC UA client writes a value and the driver reports a failure, the server automatically
|
||||||
|
reverts the variable node to its prior value and surfaces the Bad-quality status code to the client.
|
||||||
|
This prevents the node from showing a phantom Good value after a device-level write error. The
|
||||||
|
revert is local to the node that received the write — no cluster coordination is required.
|
||||||
|
|
||||||
|
Additionally, an `AuditWriteUpdateEvent` is emitted on every write attempt (success or failure),
|
||||||
|
consistent with OPC UA Part 4 audit requirements. The event carries the source node id, the
|
||||||
|
requested value, and the final status code so audit log consumers can trace write outcomes without
|
||||||
|
polling the node.
|
||||||
|
|
||||||
|
(Implemented in master `1d797c1c`.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Audit Logging
|
## Audit Logging
|
||||||
|
|
||||||
- **Server**: authentication, certificate-validation, and write-denial events are logged through the regular Serilog rolling file sink.
|
- **Server**: authentication, certificate-validation, write, and write-denial events are logged through the regular Serilog rolling file sink; write outcomes also emit an `AuditWriteUpdateEvent` (see [Write-Outcome Self-Correction](#write-outcome-self-correction)).
|
||||||
- **Admin**: `AuditWriterActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`) writes `ConfigAuditLog` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs`) to the Config DB for publish, rollback, cluster-node CRUD, and credential rotation. Visible on the cluster Audit page (`ClusterAudit.razor`) for operators with `Viewer` or above.
|
- **Admin**: `AuditWriterActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`) writes `ConfigAuditLog` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs`) to the Config DB for publish, rollback, cluster-node CRUD, and credential rotation. Visible on the cluster Audit page (`ClusterAudit.razor`) for operators with `Viewer` or above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
|||||||
{
|
{
|
||||||
Receive<ClusterEvent.IMemberEvent>(e => owner.HandleMemberEvent(e));
|
Receive<ClusterEvent.IMemberEvent>(e => owner.HandleMemberEvent(e));
|
||||||
Receive<ClusterEvent.RoleLeaderChanged>(e => owner.HandleRoleLeaderChanged(e));
|
Receive<ClusterEvent.RoleLeaderChanged>(e => owner.HandleRoleLeaderChanged(e));
|
||||||
Receive<ClusterEvent.LeaderChanged>(_ => { /* no-op for now; reserved for ServiceLevel calc */ });
|
// LeaderChanged is intentionally a no-op here: ServiceLevel lives in RedundancyStateActor
|
||||||
|
// (admin-role singleton) and has no consumer on this ClusterRoleInfo path by design.
|
||||||
|
Receive<ClusterEvent.LeaderChanged>(_ => { });
|
||||||
Receive<ClusterEvent.CurrentClusterState>(_ => { /* seeded from initial snapshot */ });
|
Receive<ClusterEvent.CurrentClusterState>(_ => { /* seeded from initial snapshot */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public static class DraftSnapshotFactory
|
|||||||
public static async Task<DraftSnapshot> FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default)
|
public static async Task<DraftSnapshot> FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default)
|
||||||
=> new DraftSnapshot
|
=> new DraftSnapshot
|
||||||
{
|
{
|
||||||
|
// GenerationId=0 and null Enterprise/Site are intentional conservative fallbacks at the
|
||||||
|
// global-factory level: the path-length validator uses its upper bound, and cross-gen
|
||||||
|
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
|
||||||
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
|
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
|
||||||
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
|
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
|
||||||
Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct),
|
Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct),
|
||||||
@@ -42,7 +45,7 @@ public static class DraftSnapshotFactory
|
|||||||
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
|
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
|
||||||
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
|
||||||
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
|
||||||
PriorEquipment = [],
|
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
|
||||||
ActiveReservations = await db.ExternalIdReservations
|
ActiveReservations = await db.ExternalIdReservations
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(r => r.ReleasedAt == null) // active only — matches DraftSnapshot.ActiveReservations semantics
|
.Where(r => r.ReleasedAt == null) // active only — matches DraftSnapshot.ActiveReservations semantics
|
||||||
|
|||||||
Reference in New Issue
Block a user