From f150460b5e6628f9dde6d97ba0b69c03a2480640 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 04:59:52 -0400 Subject: [PATCH] docs(adminui): cert-store actions design (trust/untrust/delete) --- .../2026-06-18-adminui-cert-actions-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/plans/2026-06-18-adminui-cert-actions-design.md diff --git a/docs/plans/2026-06-18-adminui-cert-actions-design.md b/docs/plans/2026-06-18-adminui-cert-actions-design.md new file mode 100644 index 00000000..c5ec7dd8 --- /dev/null +++ b/docs/plans/2026-06-18-adminui-cert-actions-design.md @@ -0,0 +1,214 @@ +# 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