215 lines
9.9 KiB
Markdown
215 lines
9.9 KiB
Markdown
# 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
|