# 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 (``, `