feat(auth): ScadaBridge TransportExport excludes inbound API keys (re-arch C4; methods-only, import ignores legacy key sections); keys re-issued per environment

This commit is contained in:
Joseph Doherty
2026-06-02 05:06:40 -04:00
parent d1191fddf9
commit 731cfd3bfc
34 changed files with 212 additions and 190 deletions
@@ -61,7 +61,9 @@ public static class BundleCommands
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names"); var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names"); var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names"); var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names"); // Inbound API keys are not transported between environments (re-arch C4) — no
// --api-keys option. Re-create keys and re-grant their method scopes on the
// destination via the admin UI/CLI.
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names"); var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
var includeDepsOption = new Option<bool>("--include-dependencies") var includeDepsOption = new Option<bool>("--include-dependencies")
{ {
@@ -85,7 +87,6 @@ public static class BundleCommands
cmd.Add(dbConnectionsOption); cmd.Add(dbConnectionsOption);
cmd.Add(notificationListsOption); cmd.Add(notificationListsOption);
cmd.Add(smtpConfigsOption); cmd.Add(smtpConfigsOption);
cmd.Add(apiKeysOption);
cmd.Add(apiMethodsOption); cmd.Add(apiMethodsOption);
cmd.Add(includeDepsOption); cmd.Add(includeDepsOption);
cmd.Add(sourceEnvOption); cmd.Add(sourceEnvOption);
@@ -106,7 +107,6 @@ public static class BundleCommands
DatabaseConnectionNames: result.GetValue(dbConnectionsOption), DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
NotificationListNames: result.GetValue(notificationListsOption), NotificationListNames: result.GetValue(notificationListsOption),
SmtpConfigurationNames: result.GetValue(smtpConfigsOption), SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
ApiKeyNames: result.GetValue(apiKeysOption),
ApiMethodNames: result.GetValue(apiMethodsOption), ApiMethodNames: result.GetValue(apiMethodsOption),
IncludeDependencies: includeDeps, IncludeDependencies: includeDeps,
Passphrase: passphrase, Passphrase: passphrase,
@@ -138,14 +138,15 @@
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs) @RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
</fieldset> </fieldset>
<fieldset class="mb-4" data-testid="group-api-keys">
<legend class="h6">API Keys</legend>
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
</fieldset>
<fieldset class="mb-4" data-testid="group-api-methods"> <fieldset class="mb-4" data-testid="group-api-methods">
<legend class="h6">API Methods</legend> <legend class="h6">API Methods</legend>
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods) @RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="api-keys-not-transported">
<strong>API keys are not part of config transport.</strong> Inbound API keys
live in each environment's own secret store and cannot be exported. After
importing, re-create the keys on the destination and re-grant their method
scopes via the admin UI/CLI.
</div>
</fieldset> </fieldset>
<div class="d-flex justify-content-end gap-2 mt-4"> <div class="d-flex justify-content-end gap-2 mt-4">
@@ -261,10 +262,7 @@
{ {
<li>SmtpConfig: @s.Host</li> <li>SmtpConfig: @s.Host</li>
} }
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase)) @* Inbound API keys are not transported (re-arch C4) — methods only. *@
{
<li>ApiKey: @k.Name</li>
}
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) @foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
{ {
<li>ApiMethod: @m.Name</li> <li>ApiMethod: @m.Name</li>
@@ -69,7 +69,7 @@ public partial class TransportExport : ComponentBase
private List<DatabaseConnectionDefinition> _dbConnections = new(); private List<DatabaseConnectionDefinition> _dbConnections = new();
private List<NotificationList> _notificationLists = new(); private List<NotificationList> _notificationLists = new();
private List<SmtpConfiguration> _smtpConfigs = new(); private List<SmtpConfiguration> _smtpConfigs = new();
private List<ApiKey> _apiKeys = new(); // Inbound API keys are not transported between environments (re-arch C4); only methods.
private List<ApiMethod> _apiMethods = new(); private List<ApiMethod> _apiMethods = new();
// ---- Step 1: selection state ---- // ---- Step 1: selection state ----
@@ -82,7 +82,7 @@ public partial class TransportExport : ComponentBase
private readonly HashSet<int> _selectedDbConnections = new(); private readonly HashSet<int> _selectedDbConnections = new();
private readonly HashSet<int> _selectedNotificationLists = new(); private readonly HashSet<int> _selectedNotificationLists = new();
private readonly HashSet<int> _selectedSmtpConfigs = new(); private readonly HashSet<int> _selectedSmtpConfigs = new();
private readonly HashSet<int> _selectedApiKeys = new(); // No _selectedApiKeys: inbound API keys are not transported (re-arch C4).
private readonly HashSet<int> _selectedApiMethods = new(); private readonly HashSet<int> _selectedApiMethods = new();
private string _filter = string.Empty; private string _filter = string.Empty;
private bool _includeDependencies = true; private bool _includeDependencies = true;
@@ -124,7 +124,7 @@ public partial class TransportExport : ComponentBase
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList(); _dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList(); _notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList(); _smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList(); // Inbound API keys are not transported (re-arch C4) — only methods are loaded.
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList(); _apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
} }
catch (Exception ex) catch (Exception ex)
@@ -169,7 +169,6 @@ public partial class TransportExport : ComponentBase
|| _selectedDbConnections.Count > 0 || _selectedDbConnections.Count > 0
|| _selectedNotificationLists.Count > 0 || _selectedNotificationLists.Count > 0
|| _selectedSmtpConfigs.Count > 0 || _selectedSmtpConfigs.Count > 0
|| _selectedApiKeys.Count > 0
|| _selectedApiMethods.Count > 0; || _selectedApiMethods.Count > 0;
private bool PassphraseValid => private bool PassphraseValid =>
@@ -205,7 +204,7 @@ public partial class TransportExport : ComponentBase
DatabaseConnectionIds: _selectedDbConnections.ToList(), DatabaseConnectionIds: _selectedDbConnections.ToList(),
NotificationListIds: _selectedNotificationLists.ToList(), NotificationListIds: _selectedNotificationLists.ToList(),
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(), SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
ApiKeyIds: _selectedApiKeys.ToList(), // Inbound API keys are not transported (re-arch C4) — methods only.
ApiMethodIds: _selectedApiMethods.ToList(), ApiMethodIds: _selectedApiMethods.ToList(),
IncludeDependencies: _includeDependencies); IncludeDependencies: _includeDependencies);
} }
@@ -393,7 +392,6 @@ public partial class TransportExport : ComponentBase
_selectedDbConnections.Clear(); _selectedDbConnections.Clear();
_selectedNotificationLists.Clear(); _selectedNotificationLists.Clear();
_selectedSmtpConfigs.Clear(); _selectedSmtpConfigs.Clear();
_selectedApiKeys.Clear();
_selectedApiMethods.Clear(); _selectedApiMethods.Clear();
_filter = string.Empty; _filter = string.Empty;
_includeDependencies = true; _includeDependencies = true;
@@ -6,6 +6,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// Exports a bundle. Names rather than IDs in the selection so test scripts can /// Exports a bundle. Names rather than IDs in the selection so test scripts can
/// be written without an ID lookup step. <c>All=true</c> overrides the per-type /// be written without an ID lookup step. <c>All=true</c> overrides the per-type
/// name lists and exports every entity of every supported type. /// name lists and exports every entity of every supported type.
/// <para>
/// Inbound API keys are intentionally not selectable: per the inbound-API-key
/// re-architecture (C4) keys are not transported between environments; only API
/// methods travel. Re-create keys and re-grant their method scopes on the
/// destination via the admin UI/CLI.
/// </para>
/// </summary> /// </summary>
public sealed record ExportBundleCommand( public sealed record ExportBundleCommand(
bool All, bool All,
@@ -15,7 +21,6 @@ public sealed record ExportBundleCommand(
IReadOnlyList<string>? DatabaseConnectionNames, IReadOnlyList<string>? DatabaseConnectionNames,
IReadOnlyList<string>? NotificationListNames, IReadOnlyList<string>? NotificationListNames,
IReadOnlyList<string>? SmtpConfigurationNames, IReadOnlyList<string>? SmtpConfigurationNames,
IReadOnlyList<string>? ApiKeyNames,
IReadOnlyList<string>? ApiMethodNames, IReadOnlyList<string>? ApiMethodNames,
bool IncludeDependencies, bool IncludeDependencies,
string? Passphrase, string? Passphrase,
@@ -1,5 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
// ApiKeys is intentionally absent: inbound API keys are not transported between
// environments (re-arch C4). Only API methods are summarised.
public sealed record BundleSummary( public sealed record BundleSummary(
int Templates, int Templates,
int TemplateFolders, int TemplateFolders,
@@ -8,5 +10,4 @@ public sealed record BundleSummary(
int DbConnections, int DbConnections,
int NotificationLists, int NotificationLists,
int SmtpConfigs, int SmtpConfigs,
int ApiKeys,
int ApiMethods); int ApiMethods);
@@ -1,5 +1,10 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
// Inbound API keys are intentionally absent from the transport selection: per the
// inbound-API-key re-architecture (commit C4) keys are NOT carried between
// environments. They live in the per-environment SQLite store (per-env pepper +
// secret-shown-once) and are re-created/re-granted via the admin UI/CLI on the
// destination. Only API *methods* travel in a bundle.
public sealed record ExportSelection( public sealed record ExportSelection(
IReadOnlyList<int> TemplateIds, IReadOnlyList<int> TemplateIds,
IReadOnlyList<int> SharedScriptIds, IReadOnlyList<int> SharedScriptIds,
@@ -7,6 +12,5 @@ public sealed record ExportSelection(
IReadOnlyList<int> DatabaseConnectionIds, IReadOnlyList<int> DatabaseConnectionIds,
IReadOnlyList<int> NotificationListIds, IReadOnlyList<int> NotificationListIds,
IReadOnlyList<int> SmtpConfigurationIds, IReadOnlyList<int> SmtpConfigurationIds,
IReadOnlyList<int> ApiKeyIds,
IReadOnlyList<int> ApiMethodIds, IReadOnlyList<int> ApiMethodIds,
bool IncludeDependencies); bool IncludeDependencies);
@@ -7,4 +7,8 @@ public sealed record ImportResult(
int Skipped, int Skipped,
int Renamed, int Renamed,
IReadOnlyList<int> StaleInstanceIds, IReadOnlyList<int> StaleInstanceIds,
string AuditEventCorrelation); string AuditEventCorrelation,
// Number of legacy inbound API keys found in the bundle that were ignored
// (re-arch C4 — keys are not transported; re-create them on this environment).
// Defaults to 0 so existing positional construction sites stay source-compatible.
int ApiKeysIgnored = 0);
@@ -1901,7 +1901,7 @@ public class ManagementActor : ReceiveActor
var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync(); var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync();
var notificationLists = await notifRepo.GetAllNotificationListsAsync(); var notificationLists = await notifRepo.GetAllNotificationListsAsync();
var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync(); var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync();
var apiKeys = await inboundRepo.GetAllApiKeysAsync(); // Inbound API keys are not transported between environments (re-arch C4); only methods.
var apiMethods = await inboundRepo.GetAllApiMethodsAsync(); var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
int[] ResolveIds<T>(IReadOnlyList<T> all, IReadOnlyList<string>? names, int[] ResolveIds<T>(IReadOnlyList<T> all, IReadOnlyList<string>? names,
@@ -1931,7 +1931,6 @@ public class ManagementActor : ReceiveActor
// SmtpConfiguration is keyed by Host (no Name column); the bundle // SmtpConfiguration is keyed by Host (no Name column); the bundle
// preview row shows the Host value, so the CLI uses Host too. // preview row shows the Host value, so the CLI uses Host too.
SmtpConfigurationIds: ResolveIds(smtpConfigs, cmd.SmtpConfigurationNames, s => s.Host, s => s.Id, "SMTP configuration"), SmtpConfigurationIds: ResolveIds(smtpConfigs, cmd.SmtpConfigurationNames, s => s.Host, s => s.Id, "SMTP configuration"),
ApiKeyIds: ResolveIds(apiKeys, cmd.ApiKeyNames, k => k.Name, k => k.Id, "API key"),
ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"), ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"),
IncludeDependencies: cmd.IncludeDependencies); IncludeDependencies: cmd.IncludeDependencies);
@@ -82,7 +82,7 @@ public sealed class BundleExporter : IBundleExporter
DatabaseConnections: resolved.DatabaseConnections, DatabaseConnections: resolved.DatabaseConnections,
NotificationLists: resolved.NotificationLists, NotificationLists: resolved.NotificationLists,
SmtpConfigurations: resolved.SmtpConfigs, SmtpConfigurations: resolved.SmtpConfigs,
ApiKeys: resolved.ApiKeys, // Inbound API keys are not transported between environments (re-arch C4).
ApiMethods: resolved.ApiMethods); ApiMethods: resolved.ApiMethods);
var contentDto = _entitySerializer.ToBundleContent(aggregate); var contentDto = _entitySerializer.ToBundleContent(aggregate);
@@ -95,7 +95,6 @@ public sealed class BundleExporter : IBundleExporter
DbConnections: resolved.DatabaseConnections.Count, DbConnections: resolved.DatabaseConnections.Count,
NotificationLists: resolved.NotificationLists.Count, NotificationLists: resolved.NotificationLists.Count,
SmtpConfigs: resolved.SmtpConfigs.Count, SmtpConfigs: resolved.SmtpConfigs.Count,
ApiKeys: resolved.ApiKeys.Count,
ApiMethods: resolved.ApiMethods.Count); ApiMethods: resolved.ApiMethods.Count);
// 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both // 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both
@@ -98,13 +98,9 @@ public sealed class DependencyResolver
if (sm is not null) smtpConfigs[sm.Id] = sm; if (sm is not null) smtpConfigs[sm.Id] = sm;
} }
var apiKeys = new Dictionary<int, ApiKey>(); // Inbound API keys are intentionally NOT resolved into the bundle: per the
foreach (var id in selection.ApiKeyIds.Distinct()) // inbound-API-key re-architecture (C4) keys are not transported between
{ // environments. Only API methods travel.
var k = await _inboundApi.GetApiKeyByIdAsync(id, ct).ConfigureAwait(false);
if (k is not null) apiKeys[k.Id] = k;
}
var apiMethods = new Dictionary<int, ApiMethod>(); var apiMethods = new Dictionary<int, ApiMethod>();
foreach (var id in selection.ApiMethodIds.Distinct()) foreach (var id in selection.ApiMethodIds.Distinct())
{ {
@@ -148,7 +144,6 @@ public sealed class DependencyResolver
dbConnections.Values, dbConnections.Values,
notificationLists.Values, notificationLists.Values,
smtpConfigs.Values, smtpConfigs.Values,
apiKeys.Values,
apiMethods.Values); apiMethods.Values);
return new ResolvedExport( return new ResolvedExport(
@@ -160,7 +155,6 @@ public sealed class DependencyResolver
DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(), DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(),
NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(), NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(),
SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(), SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(),
ApiKeys: apiKeys.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
ContentManifest: manifest); ContentManifest: manifest);
} }
@@ -368,7 +362,6 @@ public sealed class DependencyResolver
IEnumerable<DatabaseConnectionDefinition> dbConnections, IEnumerable<DatabaseConnectionDefinition> dbConnections,
IEnumerable<NotificationList> notificationLists, IEnumerable<NotificationList> notificationLists,
IEnumerable<SmtpConfiguration> smtpConfigs, IEnumerable<SmtpConfiguration> smtpConfigs,
IEnumerable<ApiKey> apiKeys,
IEnumerable<ApiMethod> apiMethods) IEnumerable<ApiMethod> apiMethods)
{ {
var entries = new List<ManifestContentEntry>(); var entries = new List<ManifestContentEntry>();
@@ -419,10 +412,7 @@ public sealed class DependencyResolver
{ {
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>())); entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>()));
} }
foreach (var k in apiKeys.OrderBy(x => x.Name, StringComparer.Ordinal)) // Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries.
{
entries.Add(new ManifestContentEntry("ApiKey", k.Name, 1, Array.Empty<string>()));
}
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal)) foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
{ {
entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty<string>())); entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty<string>()));
@@ -1,5 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; // ApiMethod
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
@@ -23,6 +23,6 @@ public sealed record ResolvedExport(
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections, IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
IReadOnlyList<NotificationList> NotificationLists, IReadOnlyList<NotificationList> NotificationLists,
IReadOnlyList<SmtpConfiguration> SmtpConfigs, IReadOnlyList<SmtpConfiguration> SmtpConfigs,
IReadOnlyList<ApiKey> ApiKeys, // Inbound API keys are not transported between environments (re-arch C4); only methods.
IReadOnlyList<ApiMethod> ApiMethods, IReadOnlyList<ApiMethod> ApiMethods,
IReadOnlyList<ManifestContentEntry> ContentManifest); IReadOnlyList<ManifestContentEntry> ContentManifest);
@@ -244,26 +244,8 @@ public sealed class ArtifactDiff
return BuildItem("SmtpConfiguration", incoming.Host, changes); return BuildItem("SmtpConfiguration", incoming.Host, changes);
} }
/// <summary> // CompareApiKey was removed in re-arch C4: inbound API keys are not transported
/// Compares an incoming API key against the existing API key in the database. // between environments, so the import preview never diffs keys.
/// </summary>
/// <param name="incoming">The incoming API key from the bundle.</param>
/// <param name="existing">The existing API key in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareApiKey(ApiKeyDto incoming, ApiKey? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("ApiKey", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "IsEnabled", existing.IsEnabled, incoming.IsEnabled);
// KeyHash is opaque — record only changed/unchanged, not the value.
if (!string.Equals(existing.KeyHash, incoming.KeyHash, StringComparison.Ordinal))
{
changes.Add(new FieldChange("KeyHash", "<changed>", "<changed>"));
}
return BuildItem("ApiKey", incoming.Name, changes);
}
/// <summary> /// <summary>
/// Compares an incoming API method against the existing API method in the database. /// Compares an incoming API method against the existing API method in the database.
@@ -277,7 +259,7 @@ public sealed class ArtifactDiff
if (existing is null) return New("ApiMethod", incoming.Name); if (existing is null) return New("ApiMethod", incoming.Name);
var changes = new List<FieldChange>(); var changes = new List<FieldChange>();
AddIfDifferent(changes, "ApprovedApiKeyIds", existing.ApprovedApiKeyIds, incoming.ApprovedApiKeyIds); // ApprovedApiKeyIds is not transported (re-arch C4) and is excluded from the diff.
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions); AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition); AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds); AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
@@ -408,14 +408,12 @@ public sealed class BundleImporter : IBundleImporter
items.Add(_diff.CompareSmtpConfiguration(sm, existing)); items.Add(_diff.CompareSmtpConfiguration(sm, existing));
} }
// ---- ApiKeys (no by-name lookup — scan GetAll) ---- // ---- ApiKeys ----
var allApiKeys = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false); // Inbound API keys are not transported between environments (re-arch C4).
var apiKeyByName = allApiKeys.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal); // New bundles never carry a keys section. A pre-C4 bundle may still contain
foreach (var k in content.ApiKeys) // one; we do NOT surface those keys as importable preview rows (they would
{ // offer Add/Overwrite actions for keys that can't be meaningfully re-created
apiKeyByName.TryGetValue(k.Name, out var existing); // from a hash). They are counted, ignored, and reported at apply time.
items.Add(_diff.CompareApiKey(k, existing));
}
// ---- ApiMethods ---- // ---- ApiMethods ----
foreach (var m in content.ApiMethods) foreach (var m in content.ApiMethods)
@@ -617,6 +615,12 @@ public sealed class BundleImporter : IBundleImporter
r => r); r => r);
var summary = new ImportSummary(); var summary = new ImportSummary();
// Inbound API keys are not transported between environments (re-arch C4).
// A pre-C4 bundle may still contain a keys section; we ignore those keys
// entirely (never re-create them) but count them so the result can tell
// the operator to re-issue keys on this environment.
var apiKeysIgnored = content.ApiKeys?.Count ?? 0;
// Set the correlation BEFORE the transaction so any audit writes // Set the correlation BEFORE the transaction so any audit writes
// triggered during the apply pick up the BundleImportId — AuditService // triggered during the apply pick up the BundleImportId — AuditService
// reads the scoped context at the moment LogAsync is called. // reads the scoped context at the moment LogAsync is called.
@@ -660,7 +664,8 @@ public sealed class BundleImporter : IBundleImporter
await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false); await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false); await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false); await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyApiKeysAsync(content.ApiKeys, resolutionMap, user, summary, ct).ConfigureAwait(false); // Inbound API keys are NOT applied from a bundle (re-arch C4) — any keys
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false); await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed // FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
@@ -700,6 +705,9 @@ public sealed class BundleImporter : IBundleImporter
summary.Skipped, summary.Skipped,
summary.Renamed, summary.Renamed,
}, },
// re-arch C4: legacy inbound API keys present in the bundle that
// were ignored (not transported / not re-created here).
ApiKeysIgnored = apiKeysIgnored,
}, },
cancellationToken: ct).ConfigureAwait(false); cancellationToken: ct).ConfigureAwait(false);
@@ -721,7 +729,8 @@ public sealed class BundleImporter : IBundleImporter
Skipped: summary.Skipped, Skipped: summary.Skipped,
Renamed: summary.Renamed, Renamed: summary.Renamed,
StaleInstanceIds: Array.Empty<int>(), StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: bundleImportId.ToString()); AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -2015,59 +2024,9 @@ public sealed class BundleImporter : IBundleImporter
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null; target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
} }
private async Task ApplyApiKeysAsync( // ApplyApiKeysAsync was removed in re-arch C4: inbound API keys are not
IReadOnlyList<ApiKeyDto> dtos, // transported between environments, so a bundle never re-creates keys. Any keys
Dictionary<(string, string), ImportResolution> map, // present in a legacy (pre-C4) bundle are counted and ignored in ApplyAsync.
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var all = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false);
var byName = all.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal);
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "ApiKey", dto.Name);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var key = ApiKey.FromHash(name, dto.KeyHash);
key.IsEnabled = dto.IsEnabled;
await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiKey", "0", name,
new { key.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
ex.KeyHash = dto.KeyHash;
ex.IsEnabled = dto.IsEnabled;
await _inboundApiRepo.UpdateApiKeyAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ApiKey", ex.Id.ToString(), ex.Name,
new { ex.Name, ex.IsEnabled }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var key = ApiKey.FromHash(dto.Name, dto.KeyHash);
key.IsEnabled = dto.IsEnabled;
await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiKey", "0", key.Name,
new { key.Name, key.IsEnabled }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyApiMethodsAsync( private async Task ApplyApiMethodsAsync(
IReadOnlyList<ApiMethodDto> dtos, IReadOnlyList<ApiMethodDto> dtos,
@@ -2098,7 +2057,9 @@ public sealed class BundleImporter : IBundleImporter
} }
case ResolutionAction.Overwrite when existing is not null: case ResolutionAction.Overwrite when existing is not null:
existing.Script = dto.Script; existing.Script = dto.Script;
existing.ApprovedApiKeyIds = dto.ApprovedApiKeyIds; // ApprovedApiKeyIds is NOT overwritten from a bundle (re-arch C4):
// method→key scopes are re-granted per environment and any value on
// the target row is preserved across an import.
existing.ParameterDefinitions = dto.ParameterDefinitions; existing.ParameterDefinitions = dto.ParameterDefinitions;
existing.ReturnDefinition = dto.ReturnDefinition; existing.ReturnDefinition = dto.ReturnDefinition;
existing.TimeoutSeconds = dto.TimeoutSeconds; existing.TimeoutSeconds = dto.TimeoutSeconds;
@@ -2124,9 +2085,10 @@ public sealed class BundleImporter : IBundleImporter
private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName) private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName)
{ {
// ApprovedApiKeyIds is intentionally left at its default (null): keys are not
// transported (re-arch C4) and method→key scopes are re-granted per environment.
return new ApiMethod(overrideName ?? dto.Name, dto.Script) return new ApiMethod(overrideName ?? dto.Name, dto.Script)
{ {
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
ParameterDefinitions = dto.ParameterDefinitions, ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition, ReturnDefinition = dto.ReturnDefinition,
TimeoutSeconds = dto.TimeoutSeconds, TimeoutSeconds = dto.TimeoutSeconds,
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
/// ignorant POCO types — what <see cref="EntitySerializer"/> consumes/produces /// ignorant POCO types — what <see cref="EntitySerializer"/> consumes/produces
/// on the application side of the bundle boundary. /// on the application side of the bundle boundary.
/// </summary> /// </summary>
// ApiKeys is intentionally absent: inbound API keys are not transported between
// environments (re-arch C4). The aggregate only carries API methods.
public sealed record EntityAggregate( public sealed record EntityAggregate(
IReadOnlyList<TemplateFolder> TemplateFolders, IReadOnlyList<TemplateFolder> TemplateFolders,
IReadOnlyList<Template> Templates, IReadOnlyList<Template> Templates,
@@ -22,7 +24,6 @@ public sealed record EntityAggregate(
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections, IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
IReadOnlyList<NotificationList> NotificationLists, IReadOnlyList<NotificationList> NotificationLists,
IReadOnlyList<SmtpConfiguration> SmtpConfigurations, IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
IReadOnlyList<ApiKey> ApiKeys,
IReadOnlyList<ApiMethod> ApiMethods); IReadOnlyList<ApiMethod> ApiMethods);
/// <summary> /// <summary>
@@ -30,6 +31,14 @@ public sealed record EntityAggregate(
/// order so importers can apply them inline. Lists are never null on the wire /// order so importers can apply them inline. Lists are never null on the wire
/// — empty arrays are preferred over nulls so JSON consumers can rely on each /// — empty arrays are preferred over nulls so JSON consumers can rely on each
/// property being present. /// property being present.
/// <para>
/// <see cref="ApiKeys"/> is a <b>legacy, read-only</b> field retained purely for
/// backward-compatible deserialization of bundles produced before the
/// inbound-API-key re-architecture (C4). New exports never populate it (it stays
/// <c>null</c> and is dropped from the JSON by the serializer's
/// <c>WhenWritingNull</c> policy); the importer counts any keys present in an old
/// bundle, ignores them, and surfaces a note — keys are re-created per environment.
/// </para>
/// </summary> /// </summary>
public sealed record BundleContentDto( public sealed record BundleContentDto(
IReadOnlyList<TemplateFolderDto> TemplateFolders, IReadOnlyList<TemplateFolderDto> TemplateFolders,
@@ -39,8 +48,8 @@ public sealed record BundleContentDto(
IReadOnlyList<DatabaseConnectionDto> DatabaseConnections, IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
IReadOnlyList<NotificationListDto> NotificationLists, IReadOnlyList<NotificationListDto> NotificationLists,
IReadOnlyList<SmtpConfigDto> SmtpConfigs, IReadOnlyList<SmtpConfigDto> SmtpConfigs,
IReadOnlyList<ApiKeyDto> ApiKeys, IReadOnlyList<ApiMethodDto> ApiMethods,
IReadOnlyList<ApiMethodDto> ApiMethods); IReadOnlyList<ApiKeyDto>? ApiKeys = null);
/// <summary> /// <summary>
/// Carved-off secret values for an entity. The outer DTO carries all non- /// Carved-off secret values for an entity. The outer DTO carries all non-
@@ -144,16 +153,20 @@ public sealed record SmtpConfigDto(
TimeSpan RetryDelay, TimeSpan RetryDelay,
SecretsBlock? Secrets); SecretsBlock? Secrets);
// Legacy DTO: only deserialized from pre-C4 bundles so the importer can count and
// ignore the keys they contain. New exports never emit an ApiKeys array.
public sealed record ApiKeyDto( public sealed record ApiKeyDto(
string Name, string Name,
string KeyHash, string KeyHash,
bool IsEnabled, bool IsEnabled,
SecretsBlock? Secrets); SecretsBlock? Secrets);
// ApprovedApiKeyIds is intentionally absent: it linked methods to keys that are no
// longer transported (re-arch C4). Scopes are re-granted per environment. The field
// in any old bundle is simply ignored on read.
public sealed record ApiMethodDto( public sealed record ApiMethodDto(
string Name, string Name,
string Script, string Script,
string? ApprovedApiKeyIds,
string? ParameterDefinitions, string? ParameterDefinitions,
string? ReturnDefinition, string? ReturnDefinition,
int TimeoutSeconds); int TimeoutSeconds);
@@ -153,17 +153,14 @@ public sealed class EntitySerializer
RetryDelay: smtp.RetryDelay, RetryDelay: smtp.RetryDelay,
Secrets: secrets); Secrets: secrets);
}).ToList(), }).ToList(),
// ApiKey stores only KeyHash already; no plaintext to carve. SecretsBlock // Inbound API keys are not transported between environments (re-arch C4):
// stays null per design — KeyHash is on the public DTO. // the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
ApiKeys: aggregate.ApiKeys.Select(k => new ApiKeyDto( // excluded — it references keys that aren't in the bundle; method→key scopes
Name: k.Name, // are re-granted per environment via the admin UI/CLI. The legacy ApiKeys
KeyHash: k.KeyHash, // field on the DTO stays null (and is dropped by WhenWritingNull).
IsEnabled: k.IsEnabled,
Secrets: null)).ToList(),
ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto( ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto(
Name: m.Name, Name: m.Name,
Script: m.Script, Script: m.Script,
ApprovedApiKeyIds: m.ApprovedApiKeyIds,
ParameterDefinitions: m.ParameterDefinitions, ParameterDefinitions: m.ParameterDefinitions,
ReturnDefinition: m.ReturnDefinition, ReturnDefinition: m.ReturnDefinition,
TimeoutSeconds: m.TimeoutSeconds)).ToList()); TimeoutSeconds: m.TimeoutSeconds)).ToList());
@@ -336,21 +333,15 @@ public sealed class EntitySerializer
}) })
.ToList(); .ToList();
var apiKeys = content.ApiKeys // Inbound API keys are not transported (re-arch C4) — content.ApiKeys is a
.Select((dto, ix) => // legacy field only present on pre-C4 bundles; it is ignored here. The
{ // BundleImporter is responsible for counting and reporting any such keys.
var key = ApiKey.FromHash(dto.Name, dto.KeyHash); // ApiMethod.ApprovedApiKeyIds is likewise not reconstructed: scopes are
key.Id = ix + 1; // re-granted per environment.
key.IsEnabled = dto.IsEnabled;
return key;
})
.ToList();
var apiMethods = content.ApiMethods var apiMethods = content.ApiMethods
.Select((dto, ix) => new ApiMethod(dto.Name, dto.Script) .Select((dto, ix) => new ApiMethod(dto.Name, dto.Script)
{ {
Id = ix + 1, Id = ix + 1,
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
ParameterDefinitions = dto.ParameterDefinitions, ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition, ReturnDefinition = dto.ReturnDefinition,
TimeoutSeconds = dto.TimeoutSeconds, TimeoutSeconds = dto.TimeoutSeconds,
@@ -366,7 +357,6 @@ public sealed class EntitySerializer
DatabaseConnections: databaseConnections, DatabaseConnections: databaseConnections,
NotificationLists: notificationLists, NotificationLists: notificationLists,
SmtpConfigurations: smtpConfigurations, SmtpConfigurations: smtpConfigurations,
ApiKeys: apiKeys,
ApiMethods: apiMethods); ApiMethods: apiMethods);
} }
} }
@@ -118,7 +118,8 @@ public class TransportExportPageTests : BunitContext
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 }; var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
var notifList = new NotificationList("Ops") { Id = 40 }; var notifList = new NotificationList("Ops") { Id = 40 };
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 }; var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
var apiKey = new ApiKey("ext-system", "key-hash") { Id = 60 }; // Inbound API keys are not transported between environments (re-arch C4) — the
// export page no longer offers a keys selection list, only API methods.
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 }; var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()) _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
@@ -135,15 +136,14 @@ public class TransportExportPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList })); .Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>()) _notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp })); .Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey> { apiKey }));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>()) _inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod })); .Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
var cut = Render<TransportExportPage>(); var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump")); cut.WaitForState(() => cut.Markup.Contains("Pump"));
// All six flat groups (plus templates) are present. // All flat groups (plus templates) are present. There is intentionally NO
// API-keys group: inbound API keys are not transported (re-arch C4).
foreach (var groupId in new[] foreach (var groupId in new[]
{ {
"group-templates", "group-templates",
@@ -152,20 +152,23 @@ public class TransportExportPageTests : BunitContext
"group-db-connections", "group-db-connections",
"group-notification-lists", "group-notification-lists",
"group-smtp-configs", "group-smtp-configs",
"group-api-keys",
"group-api-methods", "group-api-methods",
}) })
{ {
Assert.NotNull(cut.Find($"[data-testid='{groupId}']")); Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
} }
// The API-keys selection group is gone, replaced by an info note explaining
// that keys must be re-created per environment.
Assert.Empty(cut.FindAll("[data-testid='group-api-keys']"));
Assert.NotNull(cut.Find("[data-testid='api-keys-not-transported']"));
// Sanity: each artifact shows its label. // Sanity: each artifact shows its label.
Assert.Contains("Helpers", cut.Markup); Assert.Contains("Helpers", cut.Markup);
Assert.Contains("ERP", cut.Markup); Assert.Contains("ERP", cut.Markup);
Assert.Contains("Hist", cut.Markup); Assert.Contains("Hist", cut.Markup);
Assert.Contains("Ops", cut.Markup); Assert.Contains("Ops", cut.Markup);
Assert.Contains("smtp.example.com", cut.Markup); Assert.Contains("smtp.example.com", cut.Markup);
Assert.Contains("ext-system", cut.Markup);
Assert.Contains("CreateOrder", cut.Markup); Assert.Contains("CreateOrder", cut.Markup);
// Next button is disabled while no selection exists. // Next button is disabled while no selection exists.
@@ -92,7 +92,7 @@ public class TransportImportPageTests : BunitContext
Iterations: 600_000, Iterations: 600_000,
SaltB64: "abc", SaltB64: "abc",
IvB64: "def"), IvB64: "def"),
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0), Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>()), Contents: Array.Empty<ManifestContentEntry>()),
DecryptedContent = Array.Empty<byte>(), DecryptedContent = Array.Empty<byte>(),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
@@ -31,7 +31,7 @@ public sealed class TransportRecordsTests
var summary = new BundleSummary( var summary = new BundleSummary(
Templates: 2, TemplateFolders: 1, SharedScripts: 3, Templates: 2, TemplateFolders: 1, SharedScripts: 3,
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1, ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4); SmtpConfigs: 1, ApiMethods: 4);
var contents = new List<ManifestContentEntry> var contents = new List<ManifestContentEntry>
{ {
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }), new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
@@ -71,7 +71,7 @@ public sealed class TransportRecordsTests
Iterations: 600_000, Iterations: 600_000,
SaltB64: "c2FsdA==", SaltB64: "c2FsdA==",
IvB64: "aXY="); IvB64: "aXY=");
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0); var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0);
var manifest = new BundleManifest( var manifest = new BundleManifest(
BundleFormatVersion: 1, BundleFormatVersion: 1,
SchemaVersion: "1.0", SchemaVersion: "1.0",
@@ -116,7 +116,6 @@ public sealed class TransportRecordsTests
DatabaseConnectionIds: new[] { 20, 21 }, DatabaseConnectionIds: new[] { 20, 21 },
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: new[] { 30 }, SmtpConfigurationIds: new[] { 30 },
ApiKeyIds: new[] { 40, 41 },
ApiMethodIds: new[] { 50 }, ApiMethodIds: new[] { 50 },
IncludeDependencies: true); IncludeDependencies: true);
@@ -137,7 +136,6 @@ public sealed class TransportRecordsTests
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
@@ -1220,7 +1220,7 @@ public class ManagementActorTests : TestKit, IDisposable
TemplateNames: null, SharedScriptNames: null, TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null, ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null, NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null, ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null, IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env"); SourceEnvironment: "test-env");
@@ -1283,7 +1283,7 @@ public class ManagementActorTests : TestKit, IDisposable
SharedScriptNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null, ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null, NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null, ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null, IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env"); SourceEnvironment: "test-env");
@@ -69,7 +69,6 @@ public sealed class CompositionImportTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
@@ -70,7 +70,6 @@ public sealed class ConflictResolutionTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
@@ -132,7 +132,6 @@ public sealed class BundleExporterTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true); IncludeDependencies: true);
@@ -222,7 +221,6 @@ public sealed class BundleExporterTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true); IncludeDependencies: true);
@@ -15,6 +15,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport; using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import; using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -96,7 +97,6 @@ public sealed class BundleImporterApplyTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
@@ -747,4 +747,96 @@ public sealed class BundleImporterApplyTests : IDisposable
} }
Assert.Equal(1, result.Overwritten); Assert.Equal(1, result.Overwritten);
} }
// ─────────────────────────────────────────────────────────────────────
// Re-arch C4 backward-compat: a LEGACY (pre-C4) bundle still carries an
// ApiKeys section. The importer must ignore those keys gracefully — it must
// NOT fail to parse, must NOT create any ApiKey rows, and must surface the
// ignored-key count on the result (so the operator knows to re-issue keys).
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_ignores_legacy_api_keys_in_bundle_without_failing()
{
// Arrange: hand-pack a legacy bundle whose content JSON contains an
// ApiKeys array plus one API method. New exports never emit ApiKeys, so
// we build the BundleContentDto directly with a populated (non-null)
// legacy ApiKeys list to faithfully simulate a pre-C4 file.
var legacyContent = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: new[]
{
new ApiMethodDto("CreateOrder", "return 1;",
ParameterDefinitions: null, ReturnDefinition: null, TimeoutSeconds: 30),
},
ApiKeys: new[]
{
new ApiKeyDto("legacy-key-a", "hash-a", IsEnabled: true, Secrets: null),
new ApiKeyDto("legacy-key-b", "hash-b", IsEnabled: false, Secrets: null),
});
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var contentBytes = serializer.SerializeContentBytes(legacyContent);
// Sanity: the packed content really does carry an ApiKeys section, so
// the test is exercising the legacy-ignore path rather than a no-op.
var contentJson = System.Text.Encoding.UTF8.GetString(contentBytes);
Assert.Contains("legacy-key-a", contentJson);
var manifest = manifestBuilder.Build(
sourceEnvironment: "legacy-env",
exportedBy: "alice",
scadaBridgeVersion: "0.9.0",
encryption: null,
summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 1),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
await using var packed = serializer.Pack(legacyContent, manifest, passphrase: null, encryptor: null);
using var ms = new MemoryStream();
await packed.CopyToAsync(ms);
ms.Position = 0;
var session = await importer.LoadAsync(ms, passphrase: null);
sessionId = session.SessionId;
}
// Act — apply with a resolution only for the method; the keys carry no
// resolutions (the preview never surfaces them) and must be ignored.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("ApiMethod", "CreateOrder", ResolutionAction.Add, null) },
user: "bob");
}
// Assert — no keys created, the method WAS created, the ignored count is
// surfaced, and the import did not fault.
await using (var scope = _provider.CreateAsyncScope())
{
var inboundRepo = scope.ServiceProvider.GetRequiredService<IInboundApiRepository>();
var keys = await inboundRepo.GetAllApiKeysAsync();
Assert.Empty(keys);
var methods = await inboundRepo.GetAllApiMethodsAsync();
Assert.Single(methods);
Assert.Equal("CreateOrder", methods[0].Name);
}
Assert.Equal(2, result.ApiKeysIgnored);
Assert.Equal(1, result.Added); // the API method
Assert.NotEqual(Guid.Empty, result.BundleImportId);
}
} }
@@ -61,7 +61,6 @@ public sealed class BundleImporterPreviewTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
@@ -181,7 +181,6 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
@@ -122,7 +122,6 @@ public sealed class RoundTripTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: notificationListIds, NotificationListIds: notificationListIds,
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true); IncludeDependencies: true);
@@ -81,7 +81,6 @@ public sealed class SemanticValidatorImportTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
@@ -84,7 +84,6 @@ public sealed class ValidationFailureTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false); IncludeDependencies: false);
@@ -25,7 +25,6 @@ public sealed class DependencyResolverTests
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(), ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true); IncludeDependencies: true);
@@ -36,7 +35,6 @@ public sealed class DependencyResolverTests
DatabaseConnectionIds: Array.Empty<int>(), DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(), NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(), SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: ids, ApiMethodIds: ids,
IncludeDependencies: true); IncludeDependencies: true);
@@ -43,7 +43,6 @@ public sealed class BundleImporterLoadTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(), DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(), NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(), SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>()); ApiMethods: Array.Empty<ApiMethodDto>());
private static BundleContentDto SmallContent() => new( private static BundleContentDto SmallContent() => new(
@@ -65,7 +64,6 @@ public sealed class BundleImporterLoadTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(), DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(), NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(), SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>()); ApiMethods: Array.Empty<ApiMethodDto>());
private sealed class TestTimeProvider : TimeProvider private sealed class TestTimeProvider : TimeProvider
@@ -132,7 +130,7 @@ public sealed class BundleImporterLoadTests
exportedBy: "alice", exportedBy: "alice",
scadaBridgeVersion: "1.0.0", scadaBridgeVersion: "1.0.0",
encryption: null, encryption: null,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0), summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(), contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes); contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase: null, encryptor: null); return serializer.Pack(content, manifest, passphrase: null, encryptor: null);
@@ -154,7 +152,7 @@ public sealed class BundleImporterLoadTests
exportedBy: "alice", exportedBy: "alice",
scadaBridgeVersion: "1.0.0", scadaBridgeVersion: "1.0.0",
encryption: seed, encryption: seed,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0), summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(), contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes); contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase, encryptor); return serializer.Pack(content, manifest, passphrase, encryptor);
@@ -450,7 +448,7 @@ public sealed class BundleImporterLoadTests
ScadaBridgeVersion: "1.0.0", ScadaBridgeVersion: "1.0.0",
ContentHash: "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(), ContentHash: "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(),
Encryption: null, Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0), Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>()); Contents: Array.Empty<ManifestContentEntry>());
var bundleStream = HandCraftZip(forwardManifest, contentBytes, encrypted: false); var bundleStream = HandCraftZip(forwardManifest, contentBytes, encrypted: false);
@@ -473,7 +471,7 @@ public sealed class BundleImporterLoadTests
exportedBy: "alice", exportedBy: "alice",
scadaBridgeVersion: "1.0.0", scadaBridgeVersion: "1.0.0",
encryption: null, encryption: null,
summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0), summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(), contents: Array.Empty<ManifestContentEntry>(),
contentBytes: originalContentBytes); contentBytes: originalContentBytes);
@@ -26,7 +26,7 @@ public sealed class BundleSessionStoreTests
ScadaBridgeVersion: "1", ScadaBridgeVersion: "1",
ContentHash: "0", ContentHash: "0",
Encryption: null, Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0), Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>()); Contents: Array.Empty<ManifestContentEntry>());
[Fact] [Fact]
@@ -21,7 +21,6 @@ public sealed class BundleSerializerTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(), DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(), NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(), SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>()); ApiMethods: Array.Empty<ApiMethodDto>());
private static BundleManifest BuildManifestFor(byte[] contentBytes, EncryptionMetadata? encryption = null) => private static BundleManifest BuildManifestFor(byte[] contentBytes, EncryptionMetadata? encryption = null) =>
@@ -30,7 +29,7 @@ public sealed class BundleSerializerTests
exportedBy: "tester", exportedBy: "tester",
scadaBridgeVersion: "1.0.0", scadaBridgeVersion: "1.0.0",
encryption: encryption, encryption: encryption,
summary: new BundleSummary(0, 1, 1, 0, 0, 0, 0, 0, 0), summary: new BundleSummary(0, 1, 1, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(), contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes); contentBytes: contentBytes);
@@ -19,7 +19,6 @@ public sealed class EntitySerializerTests
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(), DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
NotificationLists: Array.Empty<NotificationList>(), NotificationLists: Array.Empty<NotificationList>(),
SmtpConfigurations: Array.Empty<SmtpConfiguration>(), SmtpConfigurations: Array.Empty<SmtpConfiguration>(),
ApiKeys: Array.Empty<ApiKey>(),
ApiMethods: Array.Empty<ApiMethod>()); ApiMethods: Array.Empty<ApiMethod>());
[Fact] [Fact]
@@ -217,7 +216,6 @@ public sealed class EntitySerializerTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(), DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(), NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(), SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>()); ApiMethods: Array.Empty<ApiMethodDto>());
var aggregate = new EntitySerializer().FromBundleContent(dto); var aggregate = new EntitySerializer().FromBundleContent(dto);
@@ -7,14 +7,14 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
public sealed class ManifestBuilderTests public sealed class ManifestBuilderTests
{ {
private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0, 0); private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0);
private static IReadOnlyList<ManifestContentEntry> NoContents => Array.Empty<ManifestContentEntry>(); private static IReadOnlyList<ManifestContentEntry> NoContents => Array.Empty<ManifestContentEntry>();
[Fact] [Fact]
public void Build_populates_summary_from_contents() public void Build_populates_summary_from_contents()
{ {
var sut = new ManifestBuilder(); var sut = new ManifestBuilder();
var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0, 0); var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0);
var contents = new[] var contents = new[]
{ {
new ManifestContentEntry("Template", "T1", 1, Array.Empty<string>()), new ManifestContentEntry("Template", "T1", 1, Array.Empty<string>()),