diff --git a/docs/plans/2026-05-29-adminui-followups.md b/docs/plans/2026-05-29-adminui-followups.md new file mode 100644 index 00000000..af4e5ccf --- /dev/null +++ b/docs/plans/2026-05-29-adminui-followups.md @@ -0,0 +1,1028 @@ +# AdminUI Deferred Follow-ups Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Complete the real deferred follow-ups behind the AdminUI's "Phase C.2 / follow-up" notes — driver collection editors (tags/devices/endpoints), a typed resilience-override form, and editable DB-backed LDAP→role mapping — then strip the now-obsolete notes and stale source comments. + +**Architecture:** A generic modal-per-row `CollectionEditor` Blazor component edits in-memory lists that the existing driver-page Save already serializes into `DriverInstance.DriverConfig`. The resilience textarea becomes a typed form bound to a nullable `ResilienceFormModel` that emits the same override JSON the runtime parser reads. LDAP role mapping moves to the already-existing `LdapGroupRoleMapping` DB entity (global rows only), wired into the cookie-login path with the appsettings map as a fallback baseline. + +**Tech Stack:** .NET 10, Blazor Server (InteractiveServer), EF Core (`OtOpcUaConfigDbContext`), xUnit + Shouldly, Bootstrap 5 markup. No bUnit — logic is tested through plain mapping/merge methods + EF in-memory. + +**Branch:** `feat/adminui-followups` (design doc: `docs/plans/2026-05-29-adminui-followups-design.md`). + +**Key facts (verified during planning):** +- Driver tag/device lists live in `DriverConfig` JSON and ARE the runtime source of truth (driver factories deserialize and poll them). The canonical `Tag` table is orthogonal. +- Driver tag/device contracts are **immutable positional records** → editors bind to **mutable row VMs** that map to/from the record. +- Resilience override JSON shape (parser `DriverResilienceOptionsParser`, case-insensitive keys): + `{ "bulkheadMaxConcurrent": int?, "bulkheadMaxQueue": int?, "recycleIntervalSeconds": int?, "capabilityPolicies": { "": { "timeoutSeconds": int?, "retryCount": int?, "breakerFailureThreshold": int? } } }`. Capabilities: `Read, Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead`. +- LDAP login: `AuthEndpoints.LoginAsync` builds role claims from `result.Roles` (which `LdapAuthService` computes from appsettings `GroupToRole`). `ILdapGroupRoleMappingService` (CRUD over `LdapGroupRoleMapping`, `AdminRole` enum: `ConfigViewer/ConfigEditor/FleetAdmin`) exists but is **not registered in DI** and **not wired** into login. +- Auth policies live in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` (`AddAuthorization`, fallback `RequireAuthenticatedUser`, existing `DriverOperator` policy). +- Test projects: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` (per-driver `*FormSerializationTests.cs`), `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests` (`RoleMapperTests.cs`, `AuthEndpointsIntegrationTests.cs`), `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests` (`LdapGroupRoleMappingServiceTests.cs`). + +**Global conventions for every task:** +- Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` after edits; it must be clean. +- Commit at the end of each task with the message shown. +- `IReadOnlyList<...>` collection round-trip tests use the page's `_jsonOpts` (camelCase, `UnmappedMemberHandling.Skip`). + +--- + +## WS1 — Driver collection editors + +### Task 1: Generic `CollectionEditor` shared component + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 9, Task 11, Task 12, Task 14, Task 16 + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/CollectionEditor.razor` + +This is a presentation component; no unit test (no bUnit). It is exercised by the Modbus proof (Task 2) and `/run` (Task 19). Keep ALL logic in the component trivial; per-driver mapping/validation is tested in Tasks 2–8. + +**Step 1: Write the component** + +```razor +@* Generic modal-per-row list editor. The parent owns the List (a MUTABLE row VM, + because driver contracts are immutable records). This renders a read-only table with + Add/Edit/Delete and a modal that edits a CLONED working copy — commit on Save, discard + on Cancel. NewRow builds a default VM; Clone copies one for the working copy; Validate + (optional) returns an error string to block commit or null to allow. *@ +@typeparam TRow + +
+
+ @Title (@Items.Count) + +
+ @if (Items.Count == 0) + { +
No @ItemNoun.ToLowerInvariant() rows.
+ } + else + { +
+ + @HeaderTemplate + + @for (var i = 0; i < Items.Count; i++) + { + var idx = i; + + @RowTemplate(Items[idx]) + + + } + +
+ + +
+
+ } +
+ +@if (_modalOpen && _working is not null) +{ + + +} + +@code { + [Parameter, EditorRequired] public List Items { get; set; } = default!; + [Parameter] public EventCallback ItemsChanged { get; set; } + [Parameter] public string Title { get; set; } = "Items"; + [Parameter] public string ItemNoun { get; set; } = "row"; + [Parameter] public string AnimationDelay { get; set; } = ".18s"; + [Parameter, EditorRequired] public RenderFragment HeaderTemplate { get; set; } = default!; + [Parameter, EditorRequired] public RenderFragment RowTemplate { get; set; } = default!; + [Parameter, EditorRequired] public RenderFragment EditTemplate { get; set; } = default!; + [Parameter, EditorRequired] public Func NewRow { get; set; } = default!; + [Parameter, EditorRequired] public Func Clone { get; set; } = default!; + [Parameter] public Func, int?, string?>? Validate { get; set; } + + private string _styleDelay => $"animation-delay:{AnimationDelay}"; + private bool _modalOpen; + private int? _editIndex; + private TRow? _working; + private string? _validationError; + + private void Add() + { + _editIndex = null; + _working = NewRow(); + _validationError = null; + _modalOpen = true; + } + + private void Edit(int index) + { + _editIndex = index; + _working = Clone(Items[index]); + _validationError = null; + _modalOpen = true; + } + + private async Task Delete(int index) + { + Items.RemoveAt(index); + await ItemsChanged.InvokeAsync(); + } + + private void Cancel() + { + _modalOpen = false; + _working = default; + _editIndex = null; + _validationError = null; + } + + private async Task Commit() + { + if (_working is null) return; + _validationError = Validate?.Invoke(_working, Items, _editIndex); + if (_validationError is not null) return; + + if (_editIndex is int i) Items[i] = _working; + else Items.Add(_working); + + _modalOpen = false; + _working = default; + _editIndex = null; + await ItemsChanged.InvokeAsync(); + } +} +``` + +**Step 2: Build** + +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` +Expected: Build succeeded, 0 errors. + +**Step 3: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/CollectionEditor.razor +git commit -m "feat(adminui): add generic CollectionEditor modal list editor" +``` + +--- + +### Task 2: Wire Modbus tag editor (proof) + round-trip test + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (reference task — Tasks 3-8 mirror it; do this first) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor` +- Reference (read for field names): `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs:283` (`ModbusTagDefinition`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs` + +`ModbusTagDefinition(string Name, ModbusRegion Region, ushort Address, ModbusDataType DataType, bool Writable=true, ModbusByteOrder ByteOrder=BigEndian, byte BitIndex=0, ushort StringLength=0, ModbusStringByteOrder StringByteOrder=HighByteFirst, bool WriteIdempotent=false, int? ArrayCount=null, double? Deadband=null, byte? UnitId=null, bool CoalesceProhibited=false)`. + +**Step 1: Add a mutable row VM + mapping inside `ModbusDriverPage.razor` `@code`** + +Add this nested class beside `FormModel`: + +```csharp +// Mutable VM for the modal editor — ModbusTagDefinition is an immutable record. +public sealed class ModbusTagRow +{ + public string Name { get; set; } = ""; + public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegister; + public int Address { get; set; } + public ModbusDataType DataType { get; set; } = ModbusDataType.Int16; + public bool Writable { get; set; } = true; + public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian; + public int BitIndex { get; set; } + public int StringLength { get; set; } + public bool WriteIdempotent { get; set; } + + public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone(); + + public static ModbusTagRow FromDefinition(ModbusTagDefinition d) => new() + { + Name = d.Name, Region = d.Region, Address = d.Address, DataType = d.DataType, + Writable = d.Writable, ByteOrder = d.ByteOrder, BitIndex = d.BitIndex, + StringLength = d.StringLength, WriteIdempotent = d.WriteIdempotent, + }; + + public ModbusTagDefinition ToDefinition() => new( + Name: Name.Trim(), Region: Region, Address: (ushort)Math.Clamp(Address, 0, 65535), + DataType: DataType, Writable: Writable, ByteOrder: ByteOrder, + BitIndex: (byte)Math.Clamp(BitIndex, 0, 255), StringLength: (ushort)Math.Clamp(StringLength, 0, 65535), + WriteIdempotent: WriteIdempotent); + + public static string? ValidateRow(ModbusTagRow row, IReadOnlyList all, int? editIndex) + { + if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required."; + for (var i = 0; i < all.Count; i++) + if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase)) + return $"Duplicate tag name '{row.Name}'."; + return null; + } +} +``` + +**Step 2: Replace the `_tags` field + load/save plumbing** + +Replace: +```csharp + private IReadOnlyList _tags = []; + private string _tagsJson = "[]"; +``` +with: +```csharp + private List _tags = []; +``` + +In `OnInitializedAsync`, replace `_tags = opts.Tags;` with: +```csharp + _tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList(); +``` +and delete the line `_tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts);` (both the one inside the else block context and the trailing one before `_loaded = true;`). After this change there is no `_tagsJson`. + +In `SubmitAsync` and `SerializeCurrentConfig`, replace `_form.ToOptions(_tags)` with: +```csharp +_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList()) +``` + +**Step 3: Replace the read-only Tags section markup** + +Replace the whole `@* Tags — read-only JSON view *@
` block (lines ~276-285) with: + +```razor + + + NameRegionAddressTypeWritable + + + @t.Name@t.Region@t.Address + @t.DataType@(t.Writable ? "yes" : "no") + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+``` + +(Leave the existing address-picker UI intact — it stays useful for copying an address into the modal.) + +**Step 4: Write the failing test** + +Add to `ModbusDriverPageFormSerializationTests.cs`: + +```csharp +[Fact] +public void TagRow_round_trips_through_definition() +{ + var row = new ModbusDriverPage.ModbusTagRow + { + Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegister, Address = 40001, + DataType = ModbusDataType.Int16, Writable = true, + }; + var def = row.ToDefinition(); + var back = ModbusDriverPage.ModbusTagRow.FromDefinition(def); + + back.Name.ShouldBe("Pump1_Speed"); + back.Address.ShouldBe(40001); + back.DataType.ShouldBe(ModbusDataType.Int16); + back.Writable.ShouldBeTrue(); +} + +[Fact] +public void Tag_list_survives_options_serialize_round_trip() +{ + var tags = new List + { + new("A", ModbusRegion.HoldingRegister, 1, ModbusDataType.Int16), + new("B", ModbusRegion.Coil, 2, ModbusDataType.Bool), + }; + var opts = new ModbusDriverPage.FormModel().ToOptions(tags); + var json = JsonSerializer.Serialize(opts, TestJsonOpts); // mirror page _jsonOpts (camelCase, Skip) + var back = JsonSerializer.Deserialize(json, TestJsonOpts)!; + back.Tags.Count.ShouldBe(2); + back.Tags[0].Name.ShouldBe("A"); +} + +[Fact] +public void ValidateRow_rejects_duplicate_name() +{ + var rows = new List { new() { Name = "A" } }; + ModbusDriverPage.ModbusTagRow.ValidateRow(new() { Name = "A" }, rows, null) + .ShouldNotBeNull(); +} +``` + +If `TestJsonOpts` isn't already present in the test file, add a private static matching the page: `new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }`. + +**Step 5: Run tests** + +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverPage"` +Expected: PASS (3 new tests green). + +**Step 6: Build the AdminUI project (Razor compile)** + +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` +Expected: 0 errors. + +**Step 7: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs +git commit -m "feat(adminui): editable Modbus tag list via CollectionEditor" +``` + +--- + +### Tasks 3-8: Roll out editors to remaining drivers (mirror Task 2) + +Each task follows the **exact** Task 2 pattern for its driver page: (a) read the driver's contract record(s), (b) add a mutable row VM (`Row`) with `Clone()`, `FromX`/`ToX`, and a `ValidateRow` (required key + duplicate check), (c) change the `_tags`/`_devices`/`_endpoints` field to `List`, fix load (`…Select(VM.FromX).ToList()`) and save (`…Select(r => r.ToX()).ToList()`), (d) replace each read-only `
` section with a ``, (e) add round-trip + validate tests to the driver's existing `*FormSerializationTests.cs`, (f) build + test + commit.
+
+**Classification:** standard · **Estimated implement time:** ~5 min each · **Parallelizable with:** each other and Tasks 9, 11, 12, 14, 16 (distinct files). All depend on Task 1; use Task 2 as the template.
+
+| Task | Driver page (`…/Components/Pages/Clusters/Drivers/`) | Contract(s) to read | Editors | Test file |
+|---|---|---|---|---|
+| 3 | `AbCipDriverPage.razor` | `Driver.AbCip.Contracts/AbCipDriverOptions.cs` (device + tag records) | Devices, Tags | `AbCipDriverPageFormSerializationTests.cs` |
+| 4 | `AbLegacyDriverPage.razor` | `Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` | Devices, Tags | `AbLegacyDriverPageFormSerializationTests.cs` |
+| 5 | `TwinCATDriverPage.razor` | `Driver.TwinCAT*/…Options.cs` | Devices, Tags | `TwinCATDriverPageFormSerializationTests.cs` |
+| 6 | `FocasDriverPage.razor` | `Driver.FOCAS*/…Options.cs` (device has `series` enum) | Devices, Tags | `FocasDriverPageFormSerializationTests.cs` |
+| 7 | `S7DriverPage.razor` | `Driver.S7*/…Options.cs` (tag record) | Tags | `S7DriverPageFormSerializationTests.cs` |
+| 8 | `OpcUaClientDriverPage.razor` | `OpcUaClientDriverOptions.EndpointUrls` (List<string>) | Endpoint URLs | `OpcUaClientDriverPageFormSerializationTests.cs` |
+
+Notes:
+- Task 8 endpoint rows: VM is `sealed class EndpointUrlRow { public string Url { get; set; } = ""; public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone(); }`; map to/from `string`; validate non-empty + well-formed `opc.tcp://`. Leave `UnsMappingTable`'s read-only JSON as-is (out of scope; only the endpoint list note is removed in Task 18).
+- Device deletes: keep a `Validate`/confirm note that tags referencing the device by host address may break — surface it in the modal helper text; no cascade logic.
+- Commit message per task: `feat(adminui): editable  tag/device list via CollectionEditor`.
+
+---
+
+## WS2 — Resilience typed form
+
+### Task 9: `ResilienceFormModel` (FromJson/ToJson) + tests
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** Tasks 1-8, 11, 12, 14, 16
+
+**Files:**
+- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs`
+- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs`
+- Reference: `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs` (JSON shape, case-insensitive)
+
+**Step 1: Write the model**
+
+```csharp
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+
+/// 
+///     Mutable, all-nullable form model for the driver resilience override. Binds the typed
+///     fields in DriverResilienceSection; null/blank = "use the driver's tier default", so a
+///     blank form serializes back to null (preserving DriverInstance.ResilienceConfig = null).
+///     Emits / reads the exact override JSON shape DriverResilienceOptionsParser consumes.
+/// 
+public sealed class ResilienceFormModel
+{
+    public static readonly string[] Capabilities =
+        ["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];
+
+    public int? BulkheadMaxConcurrent { get; set; }
+    public int? BulkheadMaxQueue { get; set; }
+    public int? RecycleIntervalSeconds { get; set; }
+
+    // capability name -> (timeout, retry, breaker), each nullable.
+    public Dictionary Policies { get; set; } =
+        Capabilities.ToDictionary(c => c, _ => new CapabilityRow());
+
+    public sealed class CapabilityRow
+    {
+        public int? TimeoutSeconds { get; set; }
+        public int? RetryCount { get; set; }
+        public int? BreakerFailureThreshold { get; set; }
+        public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
+    }
+
+    private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
+    private static readonly JsonSerializerOptions WriteOpts = new()
+    {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+    };
+
+    public static ResilienceFormModel FromJson(string? json)
+    {
+        var model = new ResilienceFormModel();
+        if (string.IsNullOrWhiteSpace(json)) return model;
+
+        Shape? shape;
+        try { shape = JsonSerializer.Deserialize(json, ReadOpts); }
+        catch (JsonException) { return model; }   // malformed -> empty form; raw view (Task 10) shows the text
+        if (shape is null) return model;
+
+        model.BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent;
+        model.BulkheadMaxQueue = shape.BulkheadMaxQueue;
+        model.RecycleIntervalSeconds = shape.RecycleIntervalSeconds;
+        if (shape.CapabilityPolicies is not null)
+            foreach (var (cap, p) in shape.CapabilityPolicies)
+                if (model.Policies.TryGetValue(cap, out var row))
+                {
+                    row.TimeoutSeconds = p.TimeoutSeconds;
+                    row.RetryCount = p.RetryCount;
+                    row.BreakerFailureThreshold = p.BreakerFailureThreshold;
+                }
+        return model;
+    }
+
+    /// Emit only the non-null overrides; returns null when nothing is overridden.
+    public string? ToJson()
+    {
+        var caps = Policies
+            .Where(kv => !kv.Value.IsEmpty)
+            .ToDictionary(kv => kv.Key, kv => new PolicyShape
+            {
+                TimeoutSeconds = kv.Value.TimeoutSeconds,
+                RetryCount = kv.Value.RetryCount,
+                BreakerFailureThreshold = kv.Value.BreakerFailureThreshold,
+            });
+
+        var hasAny = BulkheadMaxConcurrent is not null || BulkheadMaxQueue is not null
+                     || RecycleIntervalSeconds is not null || caps.Count > 0;
+        if (!hasAny) return null;
+
+        var shape = new Shape
+        {
+            BulkheadMaxConcurrent = BulkheadMaxConcurrent,
+            BulkheadMaxQueue = BulkheadMaxQueue,
+            RecycleIntervalSeconds = RecycleIntervalSeconds,
+            CapabilityPolicies = caps.Count > 0 ? caps : null,
+        };
+        return JsonSerializer.Serialize(shape, WriteOpts);
+    }
+
+    private sealed class Shape
+    {
+        public int? BulkheadMaxConcurrent { get; set; }
+        public int? BulkheadMaxQueue { get; set; }
+        public int? RecycleIntervalSeconds { get; set; }
+        public Dictionary? CapabilityPolicies { get; set; }
+    }
+
+    private sealed class PolicyShape
+    {
+        public int? TimeoutSeconds { get; set; }
+        public int? RetryCount { get; set; }
+        public int? BreakerFailureThreshold { get; set; }
+    }
+}
+```
+
+**Step 2: Write the failing tests**
+
+```csharp
+using System.Text.Json;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Resilience;
+
+public class ResilienceFormModelTests
+{
+    [Fact]
+    public void Blank_form_serializes_to_null()
+        => new ResilienceFormModel().ToJson().ShouldBeNull();
+
+    [Fact]
+    public void Partial_override_round_trips()
+    {
+        var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
+        m.Policies["Read"].TimeoutSeconds = 5;
+        m.Policies["Read"].RetryCount = 5;
+
+        var json = m.ToJson();
+        json.ShouldNotBeNull();
+
+        var back = ResilienceFormModel.FromJson(json);
+        back.BulkheadMaxConcurrent.ShouldBe(16);
+        back.Policies["Read"].TimeoutSeconds.ShouldBe(5);
+        back.Policies["Write"].IsEmpty.ShouldBeTrue();
+    }
+
+    [Fact]
+    public void Emitted_json_is_consumable_by_the_runtime_parser()
+    {
+        var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
+        m.Policies["Read"].TimeoutSeconds = 7;
+
+        var opts = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, m.ToJson(), out var diag);
+        diag.ShouldBeNull();
+        opts.BulkheadMaxConcurrent.ShouldBe(16);
+        opts.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
+        // untouched capability keeps the tier-B default retry count
+        opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
+    }
+}
+```
+
+**Step 3: Run tests** — `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModel"` → PASS.
+
+**Step 4: Commit**
+
+```bash
+git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs
+git commit -m "feat(adminui): typed resilience override form model + tests"
+```
+
+---
+
+### Task 10: Rewrite `DriverResilienceSection.razor` to the typed form
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none (depends on Task 9)
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor`
+
+The component keeps its `[Parameter] string? ResilienceConfig` + `ResilienceConfigChanged` contract (so all 9 driver pages need NO change). Internally it parses to a `ResilienceFormModel`, binds typed fields, and on any change re-emits `ToJson()`. A collapsible "raw JSON" `
` keeps the escape hatch. + +**Step 1: Replace the file body** (drop the stale "matches legacy DriverEdit" comment): + +```razor +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers + +
+
Resilience overrides (optional)
+
+

Blank fields use the driver type's stability-tier defaults + (see docs/v2/driver-stability.md). Set only what you need to override.

+ +
+
+
+
+
+
+
+
+ +
+ + + + @foreach (var cap in ResilienceFormModel.Capabilities) + { + var row = _m.Policies[cap]; + + + + + + + } + +
CapabilityTimeout (s)RetriesBreaker threshold
@cap
+
+ +
+ Raw JSON (advanced) +
@(_m.ToJson() ?? "(null — all tier defaults)")
+
+
+
+ +@code { + [Parameter] public string? ResilienceConfig { get; set; } + [Parameter] public EventCallback ResilienceConfigChanged { get; set; } + + private ResilienceFormModel _m = new(); + private string? _lastParsed; + + protected override void OnParametersSet() + { + // Re-parse only when the inbound value actually changed (avoid clobbering edits on re-render). + if (!string.Equals(_lastParsed, ResilienceConfig, StringComparison.Ordinal)) + { + _m = ResilienceFormModel.FromJson(ResilienceConfig); + _lastParsed = ResilienceConfig; + } + } + + private async Task EmitAsync() + { + var json = _m.ToJson(); + _lastParsed = json; + ResilienceConfig = json; + await ResilienceConfigChanged.InvokeAsync(json); + } +} +``` + +**Step 2: Build** — `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` → 0 errors. + +**Step 3: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor +git commit -m "feat(adminui): typed resilience override form replaces JSON textarea" +``` + +--- + +## WS3 — Editable DB-backed LDAP→role map (global) + +### Task 11: `RoleMapper` DB-merge overload + tests + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 1-10, 12, 14, 16 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs` + +**Semantics:** final roles = the appsettings/dev baseline (`result.Roles`) **∪** the system-wide DB grants. Additive (a group's DB grant adds to, never removes, the baseline). This preserves `DevStubMode`'s FleetAdmin and the empty-DB/DB-down case (baseline only). Cluster-scoped rows are ignored (global-only). + +**Step 1: Add the overload** + +```csharp +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +// …existing Map(groups, dict) stays… + +/// +/// Merge the appsettings-derived baseline roles with system-wide DB grants. DB rows are +/// additive; cluster-scoped rows (IsSystemWide == false) are ignored under the global model. +/// +/// Roles already resolved from appsettings (or the dev stub). +/// LdapGroupRoleMapping rows for the user's groups (from GetByGroupsAsync). +public static IReadOnlyList Merge( + IReadOnlyCollection baselineRoles, + IReadOnlyCollection dbRows) +{ + var roles = new HashSet(baselineRoles, StringComparer.OrdinalIgnoreCase); + foreach (var row in dbRows) + if (row.IsSystemWide) + roles.Add(row.Role.ToString()); + return [.. roles]; +} +``` + +**Step 2: Write the failing tests** + +```csharp +[Fact] +public void Merge_unions_baseline_and_systemwide_db_roles() +{ + var rows = new[] + { + new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true }, + new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" }, + }; + var result = RoleMapper.Merge(["ConfigViewer"], rows); + result.ShouldContain("ConfigViewer"); + result.ShouldContain("FleetAdmin"); + result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only) +} + +[Fact] +public void Merge_with_no_db_rows_returns_baseline() + => RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]); +``` + +(Add `using ZB.MOM.WW.OtOpcUa.Configuration.Entities;` and `…Enums;`.) + +**Step 3: Run** — `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~RoleMapper"` → PASS. + +**Step 4: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs +git commit -m "feat(security): RoleMapper.Merge — additive DB-backed role grants" +``` + +--- + +### Task 12: Register `ILdapGroupRoleMappingService` in DI + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Tasks 1-11, 14, 16 + +**Files:** +- Modify: the composition root(s) where `OtOpcUaConfigDbContext`/config services are registered. Find with: + `grep -rIn "AddDbContextFactory\|AddOtOpcUa\|AddConfigurationServices" src/Server --include='*.cs' | grep -v /bin/` + Likely an `AddConfiguration…`/`ServiceCollectionExtensions` in `ZB.MOM.WW.OtOpcUa.Configuration` or the AdminUI/Host `Program.cs`. + +**Step 1:** Register the scoped service next to the config DbContext registration (both the Admin UI process and the auth/login host need it): + +```csharp +services.AddScoped(); +``` + +(Add `using ZB.MOM.WW.OtOpcUa.Configuration.Services;`.) + +**Step 2: Build** the solution → 0 errors. + +**Step 3: Commit** + +```bash +git commit -am "chore(di): register ILdapGroupRoleMappingService" +``` + +--- + +### Task 13: Wire DB merge into `AuthEndpoints.LoginAsync` + +**Classification:** high-risk (auth path) +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on Tasks 11, 12) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` (keep green; add a DB-backed-role case if the fixture supports it). + +**Step 1: Inject the service and merge before building claims.** Change the `LoginAsync` signature to add the service, and replace the role-claim loop. + +Signature: +```csharp +private static async Task LoginAsync( + HttpContext http, + ILdapAuthService ldap, + ILdapGroupRoleMappingService roleMappings, + CancellationToken ct) +``` + +After the `if (!result.Success)` block and before building `claims`, compute merged roles: +```csharp + IReadOnlyList roles = result.Roles; + try + { + var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct); + roles = RoleMapper.Merge(result.Roles, dbRows); + } + catch (Exception ex) + { + // DB hiccup must never block sign-in — fall back to the appsettings baseline. + http.RequestServices.GetService()? + .CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints") + .LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline", username); + } +``` +Then change the claim loop to iterate `roles` instead of `result.Roles`: +```csharp + foreach (var role in roles) + claims.Add(new Claim(ClaimTypes.Role, role)); +``` +(Add `using Microsoft.Extensions.DependencyInjection;` and `using Microsoft.Extensions.Logging;` and `using ZB.MOM.WW.OtOpcUa.Configuration.Services;`.) + +Confirm `LdapAuthResult` exposes `Groups` (it does: positional record `(Success, DisplayName, Username, Groups, Roles, Error)`). + +**Step 2: Build** the solution → 0 errors. + +**Step 3: Run the security tests** + +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests` +Expected: all PASS (existing login/logout integration tests still green — the minimal-API handler resolves the new scoped service from the test host's DI; if the test host doesn't register it, register it in that fixture). + +**Step 4: Commit** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +git commit -m "feat(security): merge DB-backed LDAP role grants into login claims" +``` + +--- + +### Task 14: Add `FleetAdmin` authorization policy + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** Tasks 1-13, 16 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:90` (inside the `AddAuthorization` lambda, beside the `DriverOperator` policy) + +**Step 1:** Add: +```csharp + o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin")); +``` + +**Step 2: Build** → 0 errors. + +**Step 3: Commit** — `git commit -am "feat(security): add FleetAdmin authorization policy"` + +--- + +### Task 15: Rewrite `RoleGrants.razor` as global CRUD (FleetAdmin-gated) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on Tasks 12, 14) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor` + +Page keeps the LDAP-binding card (from `IOptionsSnapshot`) and shows the appsettings `GroupToRole` map read-only as "fallback (appsettings)". Adds a DB-backed editable table over `ILdapGroupRoleMappingService` (system-wide rows only). Gate the whole page to FleetAdmin. + +**Step 1: Replace the file.** Drop the stale "deferred" comment + notice. Set `@attribute [Authorize(Policy = "FleetAdmin")]`. Inject `IDbContextFactory`? No — inject `ILdapGroupRoleMappingService` directly. + +Key `@code` shape (full markup mirrors existing cards/tables + a small add-row form + a CollectionEditor-free inline table with Delete; add is a 2-field form: group + AdminRole): + +```razor +@page "/role-grants" +@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")] +@rendermode RenderMode.InteractiveServer +@using Microsoft.Extensions.Options +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@using ZB.MOM.WW.OtOpcUa.Configuration.Services +@using ZB.MOM.WW.OtOpcUa.Security.Ldap +@inject IOptionsSnapshot Ldap +@inject ILdapGroupRoleMappingService RoleMappings + +@code { + private LdapOptions? _options; + private IReadOnlyList _rows = []; + private string _newGroup = ""; + private AdminRole _newRole = AdminRole.ConfigViewer; + private string? _error; + + protected override async Task OnInitializedAsync() + { + _options = Ldap.Value; + await ReloadAsync(); + } + + private async Task ReloadAsync() + => _rows = (await RoleMappings.ListAllAsync(default)).Where(r => r.IsSystemWide).ToList(); + + private async Task AddAsync() + { + _error = null; + if (string.IsNullOrWhiteSpace(_newGroup)) { _error = "LDAP group is required."; return; } + try + { + await RoleMappings.CreateAsync(new LdapGroupRoleMapping + { + LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null, + }, default); + _newGroup = ""; + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } // e.g. duplicate (group) unique-index violation + } + + private async Task DeleteAsync(Guid id) + { + _error = null; + try { await RoleMappings.DeleteAsync(id, default); await ReloadAsync(); } + catch (Exception ex) { _error = ex.Message; } + } +} +``` + +Markup: keep the existing "LDAP binding" card; add a panel "Group → role (database)" with an add-row form (``, `