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
@@ -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,