From 77cc39e6a7b009addbb0109fd9705ecc05ea40ef Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 05:01:29 -0400 Subject: [PATCH] docs(adminui): cert-store actions implementation plan + tasks --- docs/plans/2026-06-18-adminui-cert-actions.md | 443 ++++++++++++++++++ ...6-06-18-adminui-cert-actions.md.tasks.json | 33 ++ 2 files changed, 476 insertions(+) create mode 100644 docs/plans/2026-06-18-adminui-cert-actions.md create mode 100644 docs/plans/2026-06-18-adminui-cert-actions.md.tasks.json diff --git a/docs/plans/2026-06-18-adminui-cert-actions.md b/docs/plans/2026-06-18-adminui-cert-actions.md new file mode 100644 index 00000000..ce28acf9 --- /dev/null +++ b/docs/plans/2026-06-18-adminui-cert-actions.md @@ -0,0 +1,443 @@ +# AdminUI Certificate Store Actions — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (here, subagent-driven-development) to implement this plan task-by-task. + +**Goal:** Add FleetAdmin-gated trust / untrust / delete actions to the read-only +`/certificates` AdminUI page, backed by an in-process filesystem +`CertificateStoreManager`, honored live by the running cert validator. + +**Architecture:** New `CertificateStoreManager` (pure BCL filesystem, by-thumbprint +moves/deletes on the PKI directory stores) injected into `Certificates.razor`, +which gains per-row action buttons (``) with +inline Blazor confirmation. No new endpoint (avoids the Traefik self-dial trap), +no SDK store dependency, no EF/Commons/proto change. + +**Tech Stack:** .NET 10, Blazor Server, BCL `X509CertificateLoader` / +`CertificateRequest`, xUnit + Shouldly. NO bUnit. + +**Design:** `docs/plans/2026-06-18-adminui-cert-actions-design.md` (committed `f150460b`). + +**Branch:** `feat/adminui-cert-actions` off master `8480e301`. + +**Hard rules:** stage by explicit path (never `git add .`); never stage +`sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, +`current.md`, `stillpending.md`, `docker-dev/docker-compose.yml`; never echo/commit +secrets; no force-push; no `--no-verify`; NO EF migration; NO Commons wire/proto +change; NO bUnit; `dangerouslyDisableSandbox: true` for all build/test/rig commands. + +--- + +### Task 1: `CertificateStoreManager` + tests (the load-bearing logic) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (Task 2 depends on the type) + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs` + +**Context:** This is greenfield — no cert-management code exists. A PKI directory +store is `{PkiStoreRoot}/{kind}/certs/*.{der,cer,crt}`. `kind` ∈ +`{own, issuer, trusted, rejected}`; only `trusted` and `rejected` are mutated. +The class mirrors how `Certificates.razor` already reads certs (BCL +`X509CertificateLoader`, `Directory.EnumerateFiles`). Operations are synchronous, +find files by **enumerate-and-match-thumbprint** (never build a path from the +thumbprint), and return a `CertActionResult` instead of throwing. + +**Step 1: Write the failing tests (TDD).** + +`CertificateStoreManagerTests.cs` — xUnit + Shouldly. Use a per-test temp dir +(`Path.Combine(Path.GetTempPath(), "otopcua-certtests-" + Guid.NewGuid())`), +`IDisposable` cleanup. Helper to seed a cert: + +```csharp +private static string SeedCert(string storeCertsDir, string cn) +{ + Directory.CreateDirectory(storeCertsDir); + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + $"CN={cn}", rsa, System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + var file = Path.Combine(storeCertsDir, $"{cn} [{cert.Thumbprint}].der"); + File.WriteAllBytes(file, cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert)); + return cert.Thumbprint!; +} +``` + +Tests (construct the manager via the `internal CertificateStoreManager(string pkiRoot)` +test ctor — the test project already has `InternalsVisibleTo` for AdminUI; if not, +add it in Task 1 to the AdminUI csproj): + +- `Trust_MovesRejectedToTrusted`: seed in `rejected/certs`; `Trust(tp)` → + `Success`; file gone from `rejected/certs`, present in `trusted/certs` + (re-enumerate + match thumbprint to assert). +- `Untrust_MovesTrustedToRejected`: symmetric. +- `Delete_Rejected_RemovesFile` and `Delete_Trusted_RemovesFile`. +- `Trust_UnknownThumbprint_FailsNoChange`: `Trust("A0".PadRight(40,'B'))` (valid + hex, not present) → `Success == false`, stores unchanged. +- `Trust_PathTraversalThumbprint_Rejected`: `Trust("../../x")` → `Fail`, no file + created/moved anywhere. +- `Delete_DisallowedStore_Fails`: `Delete("own", tp)` → `Fail` (error mentions + store), the `own` store untouched. +- `Trust_AlreadyInDest_IdempotentSuccess`: seed the SAME cert bytes in both + `rejected/certs` and `trusted/certs`; `Trust(tp)` → `Success`, source removed, + dest still has it. + +**Step 2: Run tests, verify they fail to compile / fail.** + +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~CertificateStoreManagerTests"` +Expected: FAIL (type does not exist yet). + +**Step 3: Implement `CertificateStoreManager`.** + +```csharp +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates; + +/// Result of a certificate-store mutation; carries a friendly error instead of throwing. +public sealed record CertActionResult(bool Success, string? Error) +{ + public static CertActionResult Ok() => new(true, null); + public static CertActionResult Fail(string error) => new(false, error); +} + +/// +/// Trusts / untrusts / deletes peer certificates by moving or removing files in the +/// OPC UA server's PKI directory stores. The running server's DirectoryStore re-enumerates +/// certs/ on each validation, so changes are honored live without a restart. +/// +public sealed class CertificateStoreManager +{ + private static readonly string[] CertExtensions = [".der", ".cer", ".crt"]; + private static readonly HashSet MutableStores = + new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" }; + + private readonly string _pkiRoot; + + /// Production ctor — reads OpcUa:PkiStoreRoot (default pki). + public CertificateStoreManager(IConfiguration config) + => _pkiRoot = config.GetValue("OpcUa:PkiStoreRoot") ?? "pki"; + + /// Test ctor — explicit PKI root. + internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot; + + /// Moves a rejected peer cert into the trusted store. + public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint); + + /// Moves a trusted peer cert back into the rejected store. + public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint); + + /// Deletes a cert from the named mutable store (trusted or rejected). + public CertActionResult Delete(string store, string thumbprint) + { + if (!MutableStores.Contains(store)) return CertActionResult.Fail($"unknown store '{store}'"); + if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint"); + try + { + var file = FindByThumbprint(CertsDir(store), thumbprint); + if (file is null) return CertActionResult.Fail($"certificate not found in {store}"); + File.Delete(file); + return CertActionResult.Ok(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return CertActionResult.Fail(ex.Message); + } + } + + private CertActionResult Move(string fromSub, string toSub, string thumbprint) + { + if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint"); + try + { + var src = FindByThumbprint(CertsDir(fromSub), thumbprint); + if (src is null) return CertActionResult.Fail($"certificate not found in {fromSub}"); + + var destDir = CertsDir(toSub); + Directory.CreateDirectory(destDir); + + // Idempotent: if dest already holds this thumbprint, just drop the source. + if (FindByThumbprint(destDir, thumbprint) is not null) + { + File.Delete(src); + return CertActionResult.Ok(); + } + + var dest = Path.Combine(destDir, Path.GetFileName(src)); + if (File.Exists(dest)) + dest = Path.Combine(destDir, + $"{Path.GetFileNameWithoutExtension(src)}_{thumbprint}{Path.GetExtension(src)}"); + File.Move(src, dest); + return CertActionResult.Ok(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return CertActionResult.Fail(ex.Message); + } + } + + private string CertsDir(string sub) => Path.Combine(_pkiRoot, sub, "certs"); + + private static string? FindByThumbprint(string certsDir, string thumbprint) + { + if (!Directory.Exists(certsDir)) return null; + foreach (var file in Directory.EnumerateFiles(certsDir)) + { + if (!CertExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue; + try + { + using var cert = X509CertificateLoader.LoadCertificateFromFile(file); + if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)) + return file; + } + catch { /* ignore unreadable entries */ } + } + return null; + } + + private static bool IsValidThumbprint(string thumbprint) => + !string.IsNullOrEmpty(thumbprint) + && (thumbprint.Length == 40 || thumbprint.Length == 64) + && thumbprint.All(Uri.IsHexDigit); +} +``` + +If the AdminUI.Tests project lacks `InternalsVisibleTo` for the AdminUI assembly, +add `` via an +`` in the AdminUI `.csproj` (check first — many projects in this repo +already have it). + +**Step 4: Run tests, verify green.** + +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~CertificateStoreManagerTests"` +Expected: PASS (all 9). + +**Step 5: Commit.** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs +# (+ the AdminUI .csproj only if you added InternalsVisibleTo) +git commit -m "feat(adminui): CertificateStoreManager — by-thumbprint trust/untrust/delete" +``` + +--- + +### Task 2: Wire actions into `Certificates.razor` + DI registration + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on Task 1's type) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor` +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` + +**Context:** The page (read it fully first) renders four stores in `OnInitialized` +via `LoadStore(label, path)` → `StoreView(Label, Path, List)`. +Add a store **kind** so the table knows which rows get actions; add an Actions +column gated by ``; add inline confirmation +(NO `window.confirm`); call the injected `CertificateStoreManager`. + +**Step 1: Register the service in DI.** + +In `EndpointRouteBuilderExtensions.AddAdminUI`, after the ScriptAnalysis +registrations, add: + +```csharp +// Certificate-store actions (trust/untrust/delete) for the /certificates page. +services.AddSingleton(); +``` + +**Step 2: Modify `Certificates.razor`.** + +Add the inject + an enum, tag each `StoreView` with its kind, render an Actions +column, and handle the confirm flow. Concrete changes: + +- Header block: add `@inject Certificates.CertificateStoreManager CertManager`. +- Replace the `StoreView` record with one carrying a kind: + +```csharp +private enum StoreKind { Own, Trusted, Issuer, Rejected } +private sealed record StoreView(string Label, StoreKind Kind, string Path, List Certificates); +``` + +- Refactor `OnInitialized()` into `LoadAll()` and call it from `OnInitialized()`: + +```csharp +protected override void OnInitialized() => LoadAll(); + +private void LoadAll() +{ + var pkiRoot = Config.GetValue("OpcUa:PkiStoreRoot") ?? "pki"; + _rows = new() + { + LoadStore("Own", StoreKind.Own, Path.Combine(pkiRoot, "own", "certs")), + LoadStore("Trusted peers", StoreKind.Trusted, Path.Combine(pkiRoot, "trusted", "certs")), + LoadStore("Trusted issuers", StoreKind.Issuer, Path.Combine(pkiRoot, "issuer", "certs")), + LoadStore("Rejected", StoreKind.Rejected, Path.Combine(pkiRoot, "rejected", "certs")), + }; + _pending = null; +} +``` +(update `LoadStore` signature to take `StoreKind kind` and pass it into `StoreView`.) + +- Add fields + the action/confirm handlers in `@code`: + +```csharp +private (StoreKind Kind, string Thumbprint, string Subject, string Verb)? _pending; +private string? _statusMsg; +private bool _statusError; + +private void RequestAction(StoreKind kind, X509Certificate2 cert, string verb) +{ + _pending = (kind, cert.Thumbprint!, cert.Subject, verb); + _statusMsg = null; +} + +private void CancelAction() => _pending = null; + +private void ConfirmAction() +{ + if (_pending is not { } p) return; + var result = p.Verb switch + { + "trust" => CertManager.Trust(p.Thumbprint), + "untrust" => CertManager.Untrust(p.Thumbprint), + "delete" => CertManager.Delete(p.Kind == StoreKind.Trusted ? "trusted" : "rejected", p.Thumbprint), + _ => CertActionResult.Fail("unknown action"), + }; + _statusError = !result.Success; + _statusMsg = result.Success + ? $"{char.ToUpper(p.Verb[0])}{p.Verb[1..]} of {p.Subject} succeeded." + : $"{p.Verb} failed: {result.Error}"; + LoadAll(); // clears _pending +} +``` + +- In the table, add an `Actions` header + cell. Render the cell's buttons only for + `Trusted`/`Rejected` and only inside ``: + +```razor +@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected) +{ + + + @if (store.Kind == StoreKind.Rejected) + { + + } + else + { + + } + + + +} +``` + +(Match the existing CSS classes used elsewhere in AdminUI — inspect a sibling page +for the real button class names, e.g. `btn`, `btn-danger`; keep it minimal and +consistent. The `Actions` `` should also be inside an `AuthorizeView` or +always-present — simplest: always render the `Actions` for trusted/rejected +tables and an empty `` for non-admins.) + +- Add the inline confirm + status banner near the top of the `else` block (after + the intro panel), e.g.: + +```razor +@if (_statusMsg is not null) +{ +
@_statusMsg
+} +@if (_pending is { } p) +{ +
+ Confirm @p.Verb of @p.Subject? + + +
+} +``` + +**Step 3: Build.** + +Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` +Expected: 0 errors. (Razor compiles; no unit test — bUnit is not used.) + +**Step 4: Commit.** + +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor \ + src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +git commit -m "feat(adminui): trust/untrust/delete actions on /certificates (FleetAdmin)" +``` + +--- + +### Task 3: Docs + full build + AdminUI test suite + live `/run` verify + finish + +**Classification:** small +**Estimated implement time:** ~4 min (+ live verify) +**Parallelizable with:** none + +**Files:** +- Modify: `docs/security.md` (one-paragraph note on the cert-store actions) + +**Step 1: Doc note.** Add a short subsection to `docs/security.md` under the cert / +PKI material: the `/certificates` page now offers FleetAdmin-gated Trust (rejected→ +trusted), Untrust (trusted→rejected), and Delete; changes are filesystem moves on +the PKI directory stores and are honored live by the validator. + +**Step 2: Full solution build.** + +Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` +Expected: 0 errors. + +**Step 3: AdminUI test suite (no regressions).** + +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests` +Expected: all green (existing + 9 new). + +**Step 4: Live `/run` verify (controller does this — login is disabled on the rig).** + +- Pick ONE central container (e.g. `otopcua-dev-central-1-1`); rebuild it if the + AdminUI changed (per the durable rig fact, `:9200` round-robins central-1/2 — to + avoid old/new flicker, rebuild BOTH, or pin the browser to a single node). +- Seed a self-signed DER into that node's `rejected/certs/` (generate locally, + `docker cp`/`docker exec` into the container's PKI rejected dir; confirm the + store path from the container's appsettings `OpcUa:PkiStoreRoot`). +- Open `http://localhost:9200/certificates` (Chrome automation), confirm the seeded + cert shows under **Rejected** with `[Trust] [Delete]`. +- Click `[Trust]` → `[Confirm]`; confirm the success banner, the cert now under + **Trusted peers**, gone from **Rejected** (and `docker exec ls` shows the file + moved on disk). +- Click `[Delete]` → `[Confirm]` on a cert; confirm removal. + +**Step 5: Finish (merge to master + push).** + +Use superpowers-extended-cc:finishing-a-development-branch. Per standing cadence: +merge `feat/adminui-cert-actions` → master, push to origin, delete the branch. +Stage by path; never stage the never-stage files. + +```bash +git add docs/security.md +git commit -m "docs(security): note cert-store trust/untrust/delete actions" +git checkout master && git merge --ff-only feat/adminui-cert-actions && git push origin master +git branch -d feat/adminui-cert-actions +``` + +--- + +## Task dependency graph + +``` +T1 (manager + tests) → T2 (razor + DI) → T3 (docs + build + tests + /run + finish) +``` + +All serial — T2 needs T1's type; T3 verifies the whole. diff --git a/docs/plans/2026-06-18-adminui-cert-actions.md.tasks.json b/docs/plans/2026-06-18-adminui-cert-actions.md.tasks.json new file mode 100644 index 00000000..b0c8fb45 --- /dev/null +++ b/docs/plans/2026-06-18-adminui-cert-actions.md.tasks.json @@ -0,0 +1,33 @@ +{ + "planPath": "docs/plans/2026-06-18-adminui-cert-actions.md", + "designPath": "docs/plans/2026-06-18-adminui-cert-actions-design.md", + "branch": "feat/adminui-cert-actions", + "baseSha": "8480e301", + "executionState": "PENDING", + "tasks": [ + { + "id": 1, + "subject": "Task 1: CertificateStoreManager + tests", + "classification": "standard", + "status": "pending", + "parallelizableWith": [] + }, + { + "id": 2, + "subject": "Task 2: Certificates.razor actions + DI registration", + "classification": "standard", + "status": "pending", + "blockedBy": [1], + "parallelizableWith": [] + }, + { + "id": 3, + "subject": "Task 3: docs + full build + AdminUI tests + live /run + finish", + "classification": "small", + "status": "pending", + "blockedBy": [2], + "parallelizableWith": [] + } + ], + "lastUpdated": "2026-06-18" +}