docs(adminui): cert-store actions design (trust/untrust/delete)

This commit is contained in:
Joseph Doherty
2026-06-18 04:59:52 -04:00
parent 8480e301ab
commit f150460b5e
@@ -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<string?>("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
`<AuthorizeView Policy="FleetAdmin">` (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<Certificates.CertificateStoreManager>();` (stateless bar
config → singleton).
## Authorization
The page stays `[Authorize]` for viewing. The **action buttons and handler are
gated by `<AuthorizeView Policy="FleetAdmin">`** (= `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/<f>.der → trusted/certs/<f>.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