# AdminUI Certificate Store Actions — Design **Date:** 2026-06-18 **Status:** Approved **Backlog item:** AdminUI Certificates page actions (reconciled ranked-OPEN list) ## Goal Turn the read-only `/certificates` AdminUI page into an operator surface that can **trust**, **untrust**, and **delete** peer certificates in the OPC UA server's PKI directory stores. Changes are honored **live** by the running server's certificate validator — no restart. ## Background — current state `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor` (`@page "/certificates"`, `[Authorize]`, `@rendermode InteractiveServer`) reads four PKI directory stores **directly from the filesystem** in `OnInitialized()` and renders a table per store (subject / issuer / thumbprint / validity). It is display-only — **zero action buttons, no backend service**. The four stores live under `{OpcUa:PkiStoreRoot}` (default `pki`), each with a `certs/` subdir holding `.der`/`.cer`/`.crt` files: | Store label | Subdir | Actionable? | |--------------------|-------------------|-------------| | Own | `own/certs` | no (read-only) | | Trusted peers | `trusted/certs` | **yes** | | Trusted issuers | `issuer/certs` | no (read-only) | | Rejected | `rejected/certs` | **yes** | The running server (`OpcUaApplicationHost.BuildConfigurationAsync`) points its `SecurityConfiguration` trust lists (`TrustedPeerCertificates`, `RejectedCertificateStore`, …) at these **same on-disk paths**. The SDK's `DirectoryStore` re-enumerates `certs/` on each validation, so a file moved into `trusted/certs` is trusted live, and one removed is no longer trusted — no process restart required. ## Action set (approved) Symmetric trust/untrust + delete: - **Rejected** rows: `[Trust]` (→ `trusted`) and `[Delete]`. - **Trusted** rows: `[Untrust]` (→ `rejected`) and `[Delete]`. - **Own** / **Issuer** rows: unchanged (read-only). Upload-to-trust is **explicitly deferred** (would add file-upload plumbing + an upload approval gate; the primary operator workflow — approve a peer that already tried to connect and landed in `rejected` — does not need it). ## Architecture ### Why an in-process service called directly (not a minimal-API endpoint) A Blazor Server component **cannot reliably dial its own HTTP endpoint server-side behind Traefik** (durable lesson: `project_blazor_server_self_hubconnection`). The page already *reads* the stores via direct in-process filesystem access in `OnInitialized`; the write actions follow the same seam — the component calls an **injected `CertificateStoreManager` directly**, server-side, within the authenticated circuit. No HttpClient self-dial, no new endpoint. ### Why pure filesystem (not the OPC UA SDK store API) A directory-store cert is just a `.der`/`.cer`/`.crt` file in `{store}/certs/`. Trust = move the file `rejected/certs → trusted/certs`; delete = remove it. The filesystem approach: - adds **no new dependency** (the page already uses BCL `X509CertificateLoader`); - is **identical to the existing read path**, so behavior is predictable; - is **trivially unit-testable** on a temp directory; - finds the file by **enumerate-and-match-thumbprint** — never by building a path from caller input — so there is **no path-injection surface**; - is honored live because the SDK `DirectoryStore` re-enumerates `certs/`. The SDK store API would add an async-store dependency for no behavioral gain on Directory stores. ## Components ### 1. `CertificateStoreManager` (NEW) `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs` ```csharp public sealed record CertActionResult(bool Success, string? Error) { public static CertActionResult Ok() => new(true, null); public static CertActionResult Fail(string error) => new(false, error); } public sealed class CertificateStoreManager { private readonly string _pkiRoot; public CertificateStoreManager(IConfiguration config) => _pkiRoot = config.GetValue("OpcUa:PkiStoreRoot") ?? "pki"; // test seam — explicit root internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot; public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint); public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint); public CertActionResult Delete(string store, string thumbprint) { … } private CertActionResult Move(string fromSub, string toSub, string thumbprint) { … } } ``` - Operations are **synchronous** filesystem moves/deletes (fast, local). Returning `CertActionResult` keeps the UI exception-free. - `Delete(store, …)` accepts only `"trusted"` or `"rejected"` (else `Fail("unknown store")`). - Thumbprint is validated as hex of length 40 (SHA-1) or 64 (SHA-256) before use — a cheap guard; path safety comes from enumerate-and-match, not from validation. - `Move`: validate thumbprint → enumerate `{root}/{fromSub}/certs/*.{der,cer,crt}`, load each, match `.Thumbprint` (case-insensitive); not found → `Fail("certificate not found in {fromSub}")`. Ensure `{root}/{toSub}/certs` exists; `File.Move(src, dest)` preserving the SDK filename, with a thumbprint suffix on name collision. If a cert with the same thumbprint already exists in the destination, the source is removed and the op is **idempotent success**. - All `IOException`/`UnauthorizedAccessException` caught → `Fail(ex.Message)`. ### 2. `Certificates.razor` (MODIFY) - Tag each `StoreView` with a `CertStoreKind` (`Own`/`Trusted`/`Issuer`/`Rejected`) so the table knows which actions to render. - Refactor `OnInitialized` → a reusable `LoadAll()` (re-run after each action). - Add an **Actions** column rendered only for `Trusted`/`Rejected`, wrapped in `` (Administrator role — the most-privileged existing policy, matching the ScriptAnalysis endpoints). Buttons set a `_pending` action record `(kind, thumbprint, subject, verb)`. - **Inline Blazor confirmation** — a banner "Confirm {verb} of {subject}? `[Confirm] [Cancel]`". **No `window.confirm`** (a JS modal dialog would block browser-automation live-verify and is disallowed by the harness). - On `Confirm`: call the injected `CertificateStoreManager`, set a status banner (green success / red error), `LoadAll()`, clear `_pending`. ### 3. DI registration (MODIFY) `AddAdminUI(IServiceCollection)` in `EndpointRouteBuilderExtensions.cs`: `services.AddSingleton();` (stateless bar config → singleton). ## Authorization The page stays `[Authorize]` for viewing. The **action buttons and handler are gated by ``** (= `RequireRole("Administrator")`). On the docker-dev rig (`DisableLogin=true`) the auto-admin satisfies the policy, so the buttons render and the actions are live-provable. ## Data flow ``` operator clicks [Trust] on a rejected cert → component sets _pending=(Rejected, thumbprint, subject, "trust") → operator clicks [Confirm] → component (server circuit) calls CertificateStoreManager.Trust(thumbprint) → File.Move rejected/certs/.der → trusted/certs/.der → live OPC UA CertificateValidator now enumerates the cert under trusted → component LoadAll() → cert now shows under "Trusted peers", gone from "Rejected" ``` ## Error handling | Condition | Result | |---|---| | invalid/missing thumbprint | `Fail` → red banner, no change | | cert not in source store (concurrent admin already moved it) | `Fail("not found")` | | dest already has the thumbprint | source removed, idempotent **success** | | `Delete` with store ∉ {trusted, rejected} | `Fail("unknown store")` | | `IOException` / access denied | caught → `Fail(message)` | ## Testing xUnit + Shouldly in `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/`, driving `CertificateStoreManager` against a **temp `PkiStoreRoot`** seeded with ephemeral self-signed DER certs (generated in-test via `CertificateRequest`/`X509Certificate2`, written to `rejected/certs`): - `Trust` moves rejected → trusted (file gone from source, present in dest). - `Untrust` moves trusted → rejected. - `Delete("rejected", …)` / `Delete("trusted", …)` removes the file. - unknown thumbprint → `Fail`, no change. - path-traversal thumbprint (`"../../x"`) → rejected by hex validation, nothing touched. - `Delete("own", …)` (disallowed store) → `Fail("unknown store")`. - idempotent re-trust (thumbprint already in trusted) → `Success`, source removed. **No bUnit** — the Razor changes are proven only by live `/run`. ## Live-verify (/run) docker-dev rig (login disabled → auto-admin). Seed a DER into a central node's `rejected/certs/`, open `http://localhost:9200/certificates`, click `[Trust]` → `[Confirm]`, verify the cert appears under **Trusted peers** and is gone from **Rejected**; click `[Delete]` on a cert, verify removal. **Rig caveat (durable):** `:9200` is Traefik-round-robined across central-1 and central-2, and each node has its **own** on-disk PKI dir. Pin the verify to a single node — seed the rejected cert into one container and drive that same container — so the read and the write line up. (`docker exec` into the chosen central container to seed + confirm the file move on disk.) ## Constraints honored No EF migration, no Commons/proto/wire change, no bUnit. Stage by explicit path (never `git add .`); never stage the never-stage files. Finish = merge to master + push. ## Touched code - **NEW** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs` - **MODIFY** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor` - **MODIFY** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (DI) - **NEW** `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs` - **(optional)** one-line note in `docs/security.md` cert-store section