Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/TransportImport.razor
T
Joseph Doherty 205a06a0f1 feat(transport): optional detached bundle signing with a pluggable key source (ScadaBridge#45)
Exports can now carry a detached ECDSA P-256 signature over the exact
manifest.json bytes in a new optional manifest.sig zip entry. The manifest
already pins the content hash, so a verified signature covers the whole
bundle, including the plaintext source environment the confirm step relies
on. Off by default (ScadaBridge:Transport:Signing): with the defaults,
exports are unsigned and imports accept signed and unsigned bundles.

- The bundle format does not change: bundleFormatVersion stays 1,
  manifest.json and the AES-GCM associated data are untouched, and older
  importers open entries by name and ignore manifest.sig. Three entries fit
  within the default MaxBundleEntryCount of 4.
- Import policy runs after the content-hash check and before any passphrase
  work. A signature that fails to verify against a trusted key is always
  refused. RequireSignedBundles additionally refuses unsigned bundles and
  bundles signed by an untrusted key. A refusal is BundleSignatureException
  (Commons), shown on the UI upload step and on CLI preview/import.
- The signature status, key id and signer are recorded on BundleSession and
  shown in the import wizard. They are also returned on PreviewBundleResult
  and recorded on the BundleImported audit row. BundleExported records
  Signed/SigningKeyId.
- Key material is pluggable. IBundleSigningKeySource and IBundleTrustStore
  are TryAdd'd; the defaults read Signing:PrivateKey (meant to be a
  ${secret:} reference) and Signing:TrustedKeys. Key ids are SPKI
  fingerprints. Startup validation rejects unparseable or non-P-256 keys.
- Compatibility proof: the checked-in fixtures pre-signing-plaintext and
  pre-signing-encrypted were exported by origin/main 3ff463b6, before this
  change, and are imported end to end with signing off and with signing
  configured.

The design leaves key-per-env vs per-org, trust distribution and rotation,
and the production default for RequireSignedBundles open. These are listed
in Component-Transport.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:42:28 -05:00

601 lines
28 KiB
Plaintext

@page "/design/transport/import"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Options
@using ZB.MOM.WW.ScadaBridge.Transport
@using ZB.MOM.WW.ScadaBridge.Transport.Import
@using System.Security.Cryptography
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@*
TransportImport wizard (Component #24, Task T22).
A 5-step linear wizard:
Step 1 — Upload : InputFile + manifest summary; LoadAsync without passphrase first.
Step 2 — Passphrase : only shown for encrypted bundles; 3-strike lockout.
Step 3 — Diff : conflict resolution (Add/Overwrite/Skip/Rename) per ImportPreviewItem.
Step 4 — Confirm : type-the-environment-name guard.
Step 5 — Result : ApplyAsync result + audit drilldown link.
The page is Admin-only — Import touches central configuration globally.
*@
<div class="container-fluid mt-3">
<h4 class="mb-3">Import Bundle</h4>
@* Step indicator — five numbered pills, mirrors TransportExport. *@
<nav aria-label="Import wizard steps" class="mb-4">
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
<li class="@StepClass(ImportWizardStep.Upload)">
<span class="badge rounded-pill me-1">1</span> Upload
</li>
<li class="@StepClass(ImportWizardStep.Passphrase)">
<span class="badge rounded-pill me-1">2</span> Passphrase
</li>
<li class="@StepClass(ImportWizardStep.Diff)">
<span class="badge rounded-pill me-1">3</span> Diff
</li>
<li class="@StepClass(ImportWizardStep.Confirm)">
<span class="badge rounded-pill me-1">4</span> Confirm
</li>
<li class="@StepClass(ImportWizardStep.Result)">
<span class="badge rounded-pill me-1">5</span> Result
</li>
</ol>
</nav>
@if (_errorMessage != null)
{
<div class="alert alert-danger" data-testid="error-message">@_errorMessage</div>
}
@switch (_step)
{
case ImportWizardStep.Upload:
@RenderStepUpload();
break;
case ImportWizardStep.Passphrase:
@RenderStepPassphrase();
break;
case ImportWizardStep.Diff:
@RenderStepDiff();
break;
case ImportWizardStep.Confirm:
@RenderStepConfirm();
break;
case ImportWizardStep.Result:
@RenderStepResult();
break;
}
</div>
@code {
private string StepClass(ImportWizardStep s) =>
s == _step ? "fw-semibold text-primary"
: (int)s < (int)_step ? "text-success"
: "text-muted";
// ============================================================
// Step 1 — Upload
// ============================================================
private RenderFragment RenderStepUpload() => __builder =>
{
<div>
@* No lede — the step pills above and the InputFile's own accept filter +
size hint already say what belongs here. *@
<div class="mb-3">
<label for="bundle-input" class="form-label">Bundle file</label>
<InputFile id="bundle-input" OnChange="OnFileSelectedAsync"
class="form-control" accept=".scadabundle,application/zip" />
<div class="form-text">
Maximum bundle size: @Options.Value.MaxBundleSizeMb MB.
</div>
</div>
@if (_uploadInProgress)
{
<div class="text-muted small fst-italic">Reading bundle…</div>
}
@if (_bundleTempPath is not null && _errorMessage is null)
{
@if (_session is not null)
{
<dl class="row small mt-3" data-testid="manifest-summary">
<dt class="col-sm-3">Source environment</dt>
<dd class="col-sm-9"><code>@_session.Manifest.SourceEnvironment</code></dd>
<dt class="col-sm-3">Exported by</dt>
<dd class="col-sm-9">@_session.Manifest.ExportedBy</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">@_session.Manifest.CreatedAtUtc.ToString("u")</dd>
<dt class="col-sm-3">Content count</dt>
<dd class="col-sm-9">@_session.Manifest.Contents.Count items</dd>
<dt class="col-sm-3">SHA-256</dt>
<dd class="col-sm-9"><code class="small">@_session.Manifest.ContentHash</code></dd>
<dt class="col-sm-3">Signature</dt>
<dd class="col-sm-9" data-testid="manifest-signature">
@switch (_session.SignatureStatus)
{
case BundleSignatureStatus.Verified:
<span class="text-success">Verified &mdash; signed by @_session.SignerName</span>
break;
case BundleSignatureStatus.UntrustedKey:
<span class="text-warning">Signed by an untrusted key</span>
<code class="small ms-1">@_session.SignerKeyId</code>
break;
default:
<span class="text-muted">Unsigned</span>
break;
}
</dd>
<dt class="col-sm-3">Encryption</dt>
<dd class="col-sm-9">
@if (_session.Manifest.Encryption is null)
{
<span class="text-warning">Unencrypted</span>
}
else
{
<span class="text-success">@_session.Manifest.Encryption.Algorithm</span>
}
</dd>
</dl>
}
else
{
<div class="alert alert-info mt-3" data-testid="encrypted-bundle-notice">
<strong>Encrypted bundle uploaded.</strong>
Click <strong>Next</strong> to enter the passphrase.
</div>
}
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-primary" @onclick="GoFromUploadAsync">Next</button>
</div>
}
</div>
};
// ============================================================
// Step 2 — Passphrase
// ============================================================
private RenderFragment RenderStepPassphrase() => __builder =>
{
var maxAttempts = Options.Value.MaxUnlockAttemptsPerSession;
var attemptsLeft = Math.Max(0, maxAttempts - _failedUnlockAttempts);
<div>
<p class="text-body-secondary">
This bundle is encrypted. Enter the passphrase that was used to
produce it. You have @attemptsLeft of @maxAttempts attempts before
the upload must be restarted.
</p>
<div class="mb-3">
<label for="import-passphrase" class="form-label">Passphrase</label>
<input id="import-passphrase" type="password" class="form-control"
autocomplete="current-password"
@bind="_passphrase" @bind:event="oninput" />
</div>
@if (_failedUnlockAttempts > 0)
{
<div class="alert alert-warning small" data-testid="unlock-attempts">
Failed unlock attempts: @_failedUnlockAttempts of @maxAttempts.
</div>
}
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
<button class="btn btn-primary"
disabled="@(string.IsNullOrEmpty(_passphrase) || _uploadInProgress)"
@onclick="SubmitPassphraseAsync">
@(_uploadInProgress ? "Unlocking…" : "Unlock")
</button>
</div>
</div>
};
// ============================================================
// Step 3 — Diff & resolve conflicts
// ============================================================
private RenderFragment RenderStepDiff() => __builder =>
{
if (_preview is null || _resolutions is null)
{
<div class="alert alert-warning">No preview available — please go back and re-upload.</div>
return;
}
var (adds, overs, skips, renames, blockers) = CountResolutions();
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
<div>
@RenderMapSection();
@* Per-kind resolution defaults are documented in Component-Transport.md
§Diff classification; the Status badge shows each row's kind in place. *@
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center" data-testid="bulk-actions">
<span class="small text-body-secondary">Apply to all modified:</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
<button class="btn btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
</div>
</div>
<div class="table-responsive" style="max-height: 480px; overflow-y: auto;">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Status</th>
<th>Existing</th>
<th>Incoming</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@foreach (var item in _preview.Items)
{
var key = (item.EntityType, item.Name);
var current = _resolutions[key];
<tr data-testid="diff-row">
<td><span class="badge bg-secondary">@item.EntityType</span></td>
@* Bundle-supplied (external) name — clip so it can't set the
table's width. .cell-clip is display:block, hence the span. *@
<td><span class="cell-clip" title="@item.Name">@item.Name</span></td>
<td>@RenderKindBadge(item)</td>
<td>@(item.ExistingVersion?.ToString() ?? "—")</td>
<td>@(item.IncomingVersion?.ToString() ?? "—")</td>
<td>@RenderResolutionControls(item, current)</td>
</tr>
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
{
var lineDiff = TryExtractLineDiff(item.FieldDiffJson);
<tr>
<td colspan="6">
<details>
<summary class="small">Field diff</summary>
@if (lineDiff is not null)
{
<div class="mt-2" data-testid="code-line-diff">
<div class="small text-body-secondary mb-1">Code changes</div>
<LineDiffView LineDiff="lineDiff" />
</div>
}
else
{
@* Raw JSON from an externally produced bundle —
wrap + cap the height rather than forcing the
already-scroll-boxed table to scroll sideways. *@
<pre class="small mb-0 detail-pre"><code>@item.FieldDiffJson</code></pre>
}
</details>
</td>
</tr>
}
@if (item.Kind == ConflictKind.Blocker && !string.IsNullOrEmpty(item.BlockerReason))
{
<tr>
<td colspan="6">
<div class="alert alert-danger small mb-0">@item.BlockerReason</div>
</td>
</tr>
}
@if (item.Kind == ConflictKind.Warning && !string.IsNullOrEmpty(item.BlockerReason))
{
<tr>
<td colspan="6">
<div class="alert alert-warning small mb-0">@item.BlockerReason</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
<div>
<span class="me-3 small text-body-secondary" data-testid="diff-summary">
@adds add · @overs overwrite · @skips skip · @renames rename · @blockers blocker
</span>
<button class="btn btn-primary"
disabled="@hasBlockers"
@onclick="GoToConfirm">
Next
</button>
</div>
</div>
</div>
};
// ============================================================
// Step 3 — Map sub-section (M8 E2)
// ============================================================
// Shown only when the preview references source-environment sites/connections
// the operator must resolve before import. For central-config-only bundles the
// preview carries no required mappings and this renders nothing.
private RenderFragment RenderMapSection() => __builder =>
{
if (_preview is null) return;
var hasSiteMappings = _preview.RequiredSiteMappings.Count > 0;
var hasConnMappings = _preview.RequiredConnectionMappings.Count > 0;
if (!hasSiteMappings && !hasConnMappings)
{
return;
}
<div class="card mb-4" data-testid="map-section">
@* The Source identifier / Source name / Map to target headings and the
"Create new" option below carry the explanation this header used to. *@
<div class="card-header bg-body-tertiary">
<strong>Resolve site &amp; connection references</strong>
</div>
<div class="card-body">
@if (hasSiteMappings)
{
<h6 class="small text-uppercase text-body-secondary">Sites</h6>
<table class="table table-sm align-middle mb-4" data-testid="map-sites-table">
<thead>
<tr>
<th>Source identifier</th>
<th>Source name</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rsm in _preview.RequiredSiteMappings)
{
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue;
<tr data-testid="map-site-row">
<td><code>@rsm.SourceSiteIdentifier</code></td>
<td><span class="cell-clip" title="@rsm.SourceSiteName">@rsm.SourceSiteName</span></td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-site-select-{rsm.SourceSiteIdentifier}")"
value="@chosen"
@onchange="e => OnSiteChoiceChangedAsync(rsm.SourceSiteIdentifier, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosen))">Create new</option>
@foreach (var site in _targetSites)
{
<option value="@site.SiteIdentifier" selected="@(chosen == site.SiteIdentifier)">
@site.Name (@site.SiteIdentifier)
</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
}
@if (hasConnMappings)
{
<h6 class="small text-uppercase text-body-secondary">Connections</h6>
@foreach (var grp in _preview.RequiredConnectionMappings.GroupBy(m => m.SourceSiteIdentifier))
{
var siteTarget = _siteChoices.TryGetValue(grp.Key, out var st) ? st : CreateNewValue;
var targetConns = ConnectionsForChosenTarget(grp.Key);
<div class="mb-3" data-testid="map-conn-group">
<div class="small text-body-secondary mb-1">
Site <code>@grp.Key</code>
@if (string.IsNullOrEmpty(siteTarget))
{
<span class="badge bg-success ms-1">new site</span>
}
</div>
<table class="table table-sm align-middle mb-0" data-testid="map-conns-table">
<thead>
<tr>
<th>Source connection</th>
<th>Map to target</th>
</tr>
</thead>
<tbody>
@foreach (var rcm in grp)
{
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue;
<tr data-testid="map-conn-row">
<td><span class="cell-clip" title="@rcm.SourceConnectionName">@rcm.SourceConnectionName</span></td>
<td>
<select class="form-select form-select-sm"
style="max-width: 22rem;"
data-testid="@($"map-conn-select-{rcm.SourceSiteIdentifier}-{rcm.SourceConnectionName}")"
value="@chosenConn"
@onchange="e => OnConnectionChoiceChanged(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, e.Value?.ToString())">
<option value="@CreateNewValue" selected="@(string.IsNullOrEmpty(chosenConn))">Create new</option>
@foreach (var conn in targetConns)
{
<option value="@conn.Name" selected="@(chosenConn == conn.Name)">@conn.Name</option>
}
</select>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
</div>
};
private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
{
var (cls, label) = item.Kind switch
{
ConflictKind.Identical => ("bg-secondary", "Identical"),
ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
ConflictKind.New => ("bg-success", "New"),
ConflictKind.Blocker => ("bg-danger", "Blocker"),
ConflictKind.Warning => ("bg-warning text-dark", "Warning"),
_ => ("bg-light text-dark", item.Kind.ToString()),
};
<span class="badge @cls">@label</span>
};
private RenderFragment RenderResolutionControls(ImportPreviewItem item, ImportResolution current) => __builder =>
{
// Identical → forced Skip; New → forced Add; Blocker → no actions.
if (item.Kind == ConflictKind.Identical)
{
<span class="text-muted small">Skip</span>
return;
}
if (item.Kind == ConflictKind.New)
{
<span class="text-muted small">Add</span>
return;
}
if (item.Kind == ConflictKind.Blocker)
{
<span class="text-muted small">—</span>
return;
}
// Warning items are advisory "Reference" rows, not importable entities —
// no resolution to choose (the import proceeds regardless).
if (item.Kind == ConflictKind.Warning)
{
<span class="text-muted small">—</span>
return;
}
var key = (item.EntityType, item.Name);
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var action in new[] { ResolutionAction.Overwrite, ResolutionAction.Skip, ResolutionAction.Rename })
{
var inputId = $"res-{item.EntityType}-{item.Name}-{action}";
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="radio"
id="@inputId"
name="@($"res-{item.EntityType}-{item.Name}")"
checked="@(current.Action == action)"
@onchange="() => SetResolution(key, action)" />
<label class="form-check-label small" for="@inputId">@action</label>
</div>
}
@if (current.Action == ResolutionAction.Rename)
{
<input type="text" class="form-control form-control-sm"
style="max-width: 14rem;"
placeholder="New name"
value="@(current.RenameTo ?? string.Empty)"
@onchange="e => SetRenameTo(key, e.Value?.ToString())" />
}
</div>
};
// ============================================================
// Step 4 — Confirm
// ============================================================
private RenderFragment RenderStepConfirm() => __builder =>
{
if (_session is null)
{
<div class="alert alert-warning">No bundle session — please re-upload.</div>
return;
}
var (adds, overs, skips, renames, _) = CountResolutions();
var changeCount = adds + overs + renames;
<div>
<p class="text-body-secondary">
You are about to apply <strong>@changeCount</strong> change(s)
to this environment (@adds add · @overs overwrite · @skips skip · @renames rename).
</p>
<div class="alert alert-info small">
Affected instances will become stale and require redeployment via the
<a href="/deployment/deployments">Deployments</a> page.
</div>
<div class="mb-3">
<label for="confirm-env" class="form-label">
Type the source environment name <code>@_session.Manifest.SourceEnvironment</code> to confirm:
</label>
<input id="confirm-env" type="text" class="form-control"
@bind="_confirmEnvironmentText" @bind:event="oninput" />
</div>
<div class="d-flex justify-content-between mt-3">
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
<button class="btn btn-danger"
disabled="@(_confirmEnvironmentText != _session.Manifest.SourceEnvironment || _applyInProgress)"
@onclick="ApplyAsync">
@(_applyInProgress ? "Applying…" : "Apply Import")
</button>
</div>
</div>
};
// ============================================================
// Step 5 — Result
// ============================================================
private RenderFragment RenderStepResult() => __builder =>
{
<div>
@if (_validationErrors is not null && _validationErrors.Count > 0)
{
<div class="alert alert-danger" data-testid="validation-errors">
<strong>Bundle semantic validation failed.</strong>
<ul class="mb-0">
@foreach (var err in _validationErrors)
{
<li>@err</li>
}
</ul>
</div>
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
}
else if (_result is not null)
{
<div class="alert alert-success" data-testid="result-summary">
<strong>Import complete.</strong>
@_result.Added added · @_result.Overwritten overwritten ·
@_result.Skipped skipped · @_result.Renamed renamed.
</div>
<dl class="row small">
<dt class="col-sm-3">Bundle Import Id</dt>
<dd class="col-sm-9"><code>@_result.BundleImportId</code></dd>
</dl>
@* Two follow-on drill-ins for the same result — anchors are valid group members. *@
<div class="btn-group" role="group">
<a class="btn btn-outline-primary" href="/deployment/deployments">
View on Deployments →
</a>
<a class="btn btn-outline-secondary"
href="@($"/audit/configuration?bundleImportId={_result.BundleImportId}")">
Audit trail →
</a>
</div>
}
else
{
<div class="alert alert-danger">
Import failed. Please re-upload the bundle.
</div>
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
}
</div>
};
}