Files
lmxopcua/docs/plans/2026-06-18-adminui-cert-actions.md
T

18 KiB

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 (<AuthorizeView Policy="FleetAdmin">) 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:

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.

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;

/// <summary>Result of a certificate-store mutation; carries a friendly error instead of throwing.</summary>
public sealed record CertActionResult(bool Success, string? Error)
{
    public static CertActionResult Ok() => new(true, null);
    public static CertActionResult Fail(string error) => new(false, error);
}

/// <summary>
/// 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
/// <c>certs/</c> on each validation, so changes are honored live without a restart.
/// </summary>
public sealed class CertificateStoreManager
{
    private static readonly string[] CertExtensions = [".der", ".cer", ".crt"];
    private static readonly HashSet<string> MutableStores =
        new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" };

    private readonly string _pkiRoot;

    /// <summary>Production ctor — reads <c>OpcUa:PkiStoreRoot</c> (default <c>pki</c>).</summary>
    public CertificateStoreManager(IConfiguration config)
        => _pkiRoot = config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";

    /// <summary>Test ctor — explicit PKI root.</summary>
    internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot;

    /// <summary>Moves a rejected peer cert into the trusted store.</summary>
    public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint);

    /// <summary>Moves a trusted peer cert back into the rejected store.</summary>
    public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint);

    /// <summary>Deletes a cert from the named mutable store (<c>trusted</c> or <c>rejected</c>).</summary>
    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 <InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.AdminUI.Tests" /> via an <ItemGroup> 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.

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<X509Certificate2>). Add a store kind so the table knows which rows get actions; add an Actions column gated by <AuthorizeView Policy="FleetAdmin">; 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:

// Certificate-store actions (trust/untrust/delete) for the /certificates page.
services.AddSingleton<Certificates.CertificateStoreManager>();

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:
private enum StoreKind { Own, Trusted, Issuer, Rejected }
private sealed record StoreView(string Label, StoreKind Kind, string Path, List<X509Certificate2> Certificates);
  • Refactor OnInitialized() into LoadAll() and call it from OnInitialized():
protected override void OnInitialized() => LoadAll();

private void LoadAll()
{
    var pkiRoot = Config.GetValue<string?>("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:
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 <AuthorizeView Policy="FleetAdmin">:
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
{
    <AuthorizeView Policy="FleetAdmin">
        <td>
            @if (store.Kind == StoreKind.Rejected)
            {
                <button class="btn-sm" @onclick="() => RequestAction(store.Kind, c, \"trust\")">Trust</button>
            }
            else
            {
                <button class="btn-sm" @onclick="() => RequestAction(store.Kind, c, \"untrust\")">Untrust</button>
            }
            <button class="btn-sm danger" @onclick="() => RequestAction(store.Kind, c, \"delete\")">Delete</button>
        </td>
    </AuthorizeView>
}

(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 <th> should also be inside an AuthorizeView or always-present — simplest: always render the <th>Actions</th> for trusted/rejected tables and an empty <td> for non-admins.)

  • Add the inline confirm + status banner near the top of the else block (after the intro panel), e.g.:
@if (_statusMsg is not null)
{
    <div class="panel @(_statusError ? "error" : "notice")">@_statusMsg</div>
}
@if (_pending is { } p)
{
    <div class="panel notice">
        Confirm <strong>@p.Verb</strong> of <span class="mono small">@p.Subject</span>?
        <button class="btn-sm" @onclick="ConfirmAction">Confirm</button>
        <button class="btn-sm" @onclick="CancelAction">Cancel</button>
    </div>
}

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.

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.

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.