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 notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list 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 includeDepsOption = new Option<bool>("--include-dependencies")
{
@@ -85,7 +87,6 @@ public static class BundleCommands
cmd.Add(dbConnectionsOption);
cmd.Add(notificationListsOption);
cmd.Add(smtpConfigsOption);
cmd.Add(apiKeysOption);
cmd.Add(apiMethodsOption);
cmd.Add(includeDepsOption);
cmd.Add(sourceEnvOption);
@@ -106,7 +107,6 @@ public static class BundleCommands
DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
NotificationListNames: result.GetValue(notificationListsOption),
SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
ApiKeyNames: result.GetValue(apiKeysOption),
ApiMethodNames: result.GetValue(apiMethodsOption),
IncludeDependencies: includeDeps,
Passphrase: passphrase,
@@ -138,14 +138,15 @@
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
</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">
<legend class="h6">API Methods</legend>
@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>
<div class="d-flex justify-content-end gap-2 mt-4">
@@ -261,10 +262,7 @@
{
<li>SmtpConfig: @s.Host</li>
}
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ApiKey: @k.Name</li>
}
@* Inbound API keys are not transported (re-arch C4) — methods only. *@
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
{
<li>ApiMethod: @m.Name</li>
@@ -69,7 +69,7 @@ public partial class TransportExport : ComponentBase
private List<DatabaseConnectionDefinition> _dbConnections = new();
private List<NotificationList> _notificationLists = 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();
// ---- Step 1: selection state ----
@@ -82,7 +82,7 @@ public partial class TransportExport : ComponentBase
private readonly HashSet<int> _selectedDbConnections = new();
private readonly HashSet<int> _selectedNotificationLists = 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 string _filter = string.Empty;
private bool _includeDependencies = true;
@@ -124,7 +124,7 @@ public partial class TransportExport : ComponentBase
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).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();
}
catch (Exception ex)
@@ -169,7 +169,6 @@ public partial class TransportExport : ComponentBase
|| _selectedDbConnections.Count > 0
|| _selectedNotificationLists.Count > 0
|| _selectedSmtpConfigs.Count > 0
|| _selectedApiKeys.Count > 0
|| _selectedApiMethods.Count > 0;
private bool PassphraseValid =>
@@ -205,7 +204,7 @@ public partial class TransportExport : ComponentBase
DatabaseConnectionIds: _selectedDbConnections.ToList(),
NotificationListIds: _selectedNotificationLists.ToList(),
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
ApiKeyIds: _selectedApiKeys.ToList(),
// Inbound API keys are not transported (re-arch C4) — methods only.
ApiMethodIds: _selectedApiMethods.ToList(),
IncludeDependencies: _includeDependencies);
}
@@ -393,7 +392,6 @@ public partial class TransportExport : ComponentBase
_selectedDbConnections.Clear();
_selectedNotificationLists.Clear();
_selectedSmtpConfigs.Clear();
_selectedApiKeys.Clear();
_selectedApiMethods.Clear();
_filter = string.Empty;
_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
/// 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.
/// <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>
public sealed record ExportBundleCommand(
bool All,
@@ -15,7 +21,6 @@ public sealed record ExportBundleCommand(
IReadOnlyList<string>? DatabaseConnectionNames,
IReadOnlyList<string>? NotificationListNames,
IReadOnlyList<string>? SmtpConfigurationNames,
IReadOnlyList<string>? ApiKeyNames,
IReadOnlyList<string>? ApiMethodNames,
bool IncludeDependencies,
string? Passphrase,
@@ -1,5 +1,7 @@
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(
int Templates,
int TemplateFolders,
@@ -8,5 +10,4 @@ public sealed record BundleSummary(
int DbConnections,
int NotificationLists,
int SmtpConfigs,
int ApiKeys,
int ApiMethods);
@@ -1,5 +1,10 @@
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(
IReadOnlyList<int> TemplateIds,
IReadOnlyList<int> SharedScriptIds,
@@ -7,6 +12,5 @@ public sealed record ExportSelection(
IReadOnlyList<int> DatabaseConnectionIds,
IReadOnlyList<int> NotificationListIds,
IReadOnlyList<int> SmtpConfigurationIds,
IReadOnlyList<int> ApiKeyIds,
IReadOnlyList<int> ApiMethodIds,
bool IncludeDependencies);
@@ -7,4 +7,8 @@ public sealed record ImportResult(
int Skipped,
int Renamed,
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 notificationLists = await notifRepo.GetAllNotificationListsAsync();
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();
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
// 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"),
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"),
IncludeDependencies: cmd.IncludeDependencies);
@@ -82,7 +82,7 @@ public sealed class BundleExporter : IBundleExporter
DatabaseConnections: resolved.DatabaseConnections,
NotificationLists: resolved.NotificationLists,
SmtpConfigurations: resolved.SmtpConfigs,
ApiKeys: resolved.ApiKeys,
// Inbound API keys are not transported between environments (re-arch C4).
ApiMethods: resolved.ApiMethods);
var contentDto = _entitySerializer.ToBundleContent(aggregate);
@@ -95,7 +95,6 @@ public sealed class BundleExporter : IBundleExporter
DbConnections: resolved.DatabaseConnections.Count,
NotificationLists: resolved.NotificationLists.Count,
SmtpConfigs: resolved.SmtpConfigs.Count,
ApiKeys: resolved.ApiKeys.Count,
ApiMethods: resolved.ApiMethods.Count);
// 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;
}
var apiKeys = new Dictionary<int, ApiKey>();
foreach (var id in selection.ApiKeyIds.Distinct())
{
var k = await _inboundApi.GetApiKeyByIdAsync(id, ct).ConfigureAwait(false);
if (k is not null) apiKeys[k.Id] = k;
}
// Inbound API keys are intentionally NOT resolved into the bundle: per the
// inbound-API-key re-architecture (C4) keys are not transported between
// environments. Only API methods travel.
var apiMethods = new Dictionary<int, ApiMethod>();
foreach (var id in selection.ApiMethodIds.Distinct())
{
@@ -148,7 +144,6 @@ public sealed class DependencyResolver
dbConnections.Values,
notificationLists.Values,
smtpConfigs.Values,
apiKeys.Values,
apiMethods.Values);
return new ResolvedExport(
@@ -160,7 +155,6 @@ public sealed class DependencyResolver
DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(),
NotificationLists: notificationLists.Values.OrderBy(n => n.Name, 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(),
ContentManifest: manifest);
}
@@ -368,7 +362,6 @@ public sealed class DependencyResolver
IEnumerable<DatabaseConnectionDefinition> dbConnections,
IEnumerable<NotificationList> notificationLists,
IEnumerable<SmtpConfiguration> smtpConfigs,
IEnumerable<ApiKey> apiKeys,
IEnumerable<ApiMethod> apiMethods)
{
var entries = new List<ManifestContentEntry>();
@@ -419,10 +412,7 @@ public sealed class DependencyResolver
{
entries.Add(new ManifestContentEntry("SmtpConfiguration", s.Host, 1, Array.Empty<string>()));
}
foreach (var k in apiKeys.OrderBy(x => x.Name, StringComparer.Ordinal))
{
entries.Add(new ManifestContentEntry("ApiKey", k.Name, 1, Array.Empty<string>()));
}
// Inbound API keys are not transported (re-arch C4) — no ApiKey manifest entries.
foreach (var m in apiMethods.OrderBy(x => x.Name, StringComparer.Ordinal))
{
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.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.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
@@ -23,6 +23,6 @@ public sealed record ResolvedExport(
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
IReadOnlyList<NotificationList> NotificationLists,
IReadOnlyList<SmtpConfiguration> SmtpConfigs,
IReadOnlyList<ApiKey> ApiKeys,
// Inbound API keys are not transported between environments (re-arch C4); only methods.
IReadOnlyList<ApiMethod> ApiMethods,
IReadOnlyList<ManifestContentEntry> ContentManifest);
@@ -244,26 +244,8 @@ public sealed class ArtifactDiff
return BuildItem("SmtpConfiguration", incoming.Host, changes);
}
/// <summary>
/// Compares an incoming API key against the existing API key in the database.
/// </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);
}
// CompareApiKey was removed in re-arch C4: inbound API keys are not transported
// between environments, so the import preview never diffs keys.
/// <summary>
/// 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);
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, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
@@ -408,14 +408,12 @@ public sealed class BundleImporter : IBundleImporter
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
}
// ---- ApiKeys (no by-name lookup — scan GetAll) ----
var allApiKeys = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false);
var apiKeyByName = allApiKeys.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal);
foreach (var k in content.ApiKeys)
{
apiKeyByName.TryGetValue(k.Name, out var existing);
items.Add(_diff.CompareApiKey(k, existing));
}
// ---- ApiKeys ----
// Inbound API keys are not transported between environments (re-arch C4).
// New bundles never carry a keys section. A pre-C4 bundle may still contain
// 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
// from a hash). They are counted, ignored, and reported at apply time.
// ---- ApiMethods ----
foreach (var m in content.ApiMethods)
@@ -617,6 +615,12 @@ public sealed class BundleImporter : IBundleImporter
r => r);
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
// triggered during the apply pick up the BundleImportId — AuditService
// 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 ApplyNotificationListsAsync(content.NotificationLists, 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);
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
@@ -700,6 +705,9 @@ public sealed class BundleImporter : IBundleImporter
summary.Skipped,
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);
@@ -721,7 +729,8 @@ public sealed class BundleImporter : IBundleImporter
Skipped: summary.Skipped,
Renamed: summary.Renamed,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: bundleImportId.ToString());
AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored);
}
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;
}
private async Task ApplyApiKeysAsync(
IReadOnlyList<ApiKeyDto> dtos,
Dictionary<(string, string), ImportResolution> map,
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;
}
}
}
}
// ApplyApiKeysAsync was removed in re-arch C4: inbound API keys are not
// transported between environments, so a bundle never re-creates keys. Any keys
// present in a legacy (pre-C4) bundle are counted and ignored in ApplyAsync.
private async Task ApplyApiMethodsAsync(
IReadOnlyList<ApiMethodDto> dtos,
@@ -2098,7 +2057,9 @@ public sealed class BundleImporter : IBundleImporter
}
case ResolutionAction.Overwrite when existing is not null:
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.ReturnDefinition = dto.ReturnDefinition;
existing.TimeoutSeconds = dto.TimeoutSeconds;
@@ -2124,9 +2085,10 @@ public sealed class BundleImporter : IBundleImporter
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)
{
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
TimeoutSeconds = dto.TimeoutSeconds,
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
/// ignorant POCO types — what <see cref="EntitySerializer"/> consumes/produces
/// on the application side of the bundle boundary.
/// </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(
IReadOnlyList<TemplateFolder> TemplateFolders,
IReadOnlyList<Template> Templates,
@@ -22,7 +24,6 @@ public sealed record EntityAggregate(
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
IReadOnlyList<NotificationList> NotificationLists,
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
IReadOnlyList<ApiKey> ApiKeys,
IReadOnlyList<ApiMethod> ApiMethods);
/// <summary>
@@ -30,6 +31,14 @@ public sealed record EntityAggregate(
/// 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
/// 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>
public sealed record BundleContentDto(
IReadOnlyList<TemplateFolderDto> TemplateFolders,
@@ -39,8 +48,8 @@ public sealed record BundleContentDto(
IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
IReadOnlyList<NotificationListDto> NotificationLists,
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
IReadOnlyList<ApiKeyDto> ApiKeys,
IReadOnlyList<ApiMethodDto> ApiMethods);
IReadOnlyList<ApiMethodDto> ApiMethods,
IReadOnlyList<ApiKeyDto>? ApiKeys = null);
/// <summary>
/// Carved-off secret values for an entity. The outer DTO carries all non-
@@ -144,16 +153,20 @@ public sealed record SmtpConfigDto(
TimeSpan RetryDelay,
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(
string Name,
string KeyHash,
bool IsEnabled,
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(
string Name,
string Script,
string? ApprovedApiKeyIds,
string? ParameterDefinitions,
string? ReturnDefinition,
int TimeoutSeconds);
@@ -153,17 +153,14 @@ public sealed class EntitySerializer
RetryDelay: smtp.RetryDelay,
Secrets: secrets);
}).ToList(),
// ApiKey stores only KeyHash already; no plaintext to carve. SecretsBlock
// stays null per design — KeyHash is on the public DTO.
ApiKeys: aggregate.ApiKeys.Select(k => new ApiKeyDto(
Name: k.Name,
KeyHash: k.KeyHash,
IsEnabled: k.IsEnabled,
Secrets: null)).ToList(),
// Inbound API keys are not transported between environments (re-arch C4):
// the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
// excluded — it references keys that aren't in the bundle; method→key scopes
// are re-granted per environment via the admin UI/CLI. The legacy ApiKeys
// field on the DTO stays null (and is dropped by WhenWritingNull).
ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto(
Name: m.Name,
Script: m.Script,
ApprovedApiKeyIds: m.ApprovedApiKeyIds,
ParameterDefinitions: m.ParameterDefinitions,
ReturnDefinition: m.ReturnDefinition,
TimeoutSeconds: m.TimeoutSeconds)).ToList());
@@ -336,21 +333,15 @@ public sealed class EntitySerializer
})
.ToList();
var apiKeys = content.ApiKeys
.Select((dto, ix) =>
{
var key = ApiKey.FromHash(dto.Name, dto.KeyHash);
key.Id = ix + 1;
key.IsEnabled = dto.IsEnabled;
return key;
})
.ToList();
// Inbound API keys are not transported (re-arch C4) — content.ApiKeys is a
// legacy field only present on pre-C4 bundles; it is ignored here. The
// BundleImporter is responsible for counting and reporting any such keys.
// ApiMethod.ApprovedApiKeyIds is likewise not reconstructed: scopes are
// re-granted per environment.
var apiMethods = content.ApiMethods
.Select((dto, ix) => new ApiMethod(dto.Name, dto.Script)
{
Id = ix + 1,
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
TimeoutSeconds = dto.TimeoutSeconds,
@@ -366,7 +357,6 @@ public sealed class EntitySerializer
DatabaseConnections: databaseConnections,
NotificationLists: notificationLists,
SmtpConfigurations: smtpConfigurations,
ApiKeys: apiKeys,
ApiMethods: apiMethods);
}
}