feat(transport): bundle entity DTOs + secret carving in EntitySerializer

This commit is contained in:
Joseph Doherty
2026-05-24 04:08:43 -04:00
parent 447bf84b13
commit ee76b84b0f
3 changed files with 688 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.Transport.Serialization;
/// <summary>
/// In-memory aggregate of all bundle-eligible Commons entities. Matches the
/// shape of <see cref="BundleContentDto"/> but uses the real persistence-
/// ignorant POCO types — what <see cref="EntitySerializer"/> consumes/produces
/// on the application side of the bundle boundary.
/// </summary>
public sealed record EntityAggregate(
IReadOnlyList<TemplateFolder> TemplateFolders,
IReadOnlyList<Template> Templates,
IReadOnlyList<SharedScript> SharedScripts,
IReadOnlyList<ExternalSystemDefinition> ExternalSystems,
IReadOnlyList<ExternalSystemMethod> ExternalSystemMethods,
IReadOnlyList<DatabaseConnectionDefinition> DatabaseConnections,
IReadOnlyList<NotificationList> NotificationLists,
IReadOnlyList<SmtpConfiguration> SmtpConfigurations,
IReadOnlyList<ApiKey> ApiKeys,
IReadOnlyList<ApiMethod> ApiMethods);
/// <summary>
/// Top-level serializable bundle payload. Lists are sequenced in dependency
/// 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.
/// </summary>
public sealed record BundleContentDto(
IReadOnlyList<TemplateFolderDto> TemplateFolders,
IReadOnlyList<TemplateDto> Templates,
IReadOnlyList<SharedScriptDto> SharedScripts,
IReadOnlyList<ExternalSystemDto> ExternalSystems,
IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
IReadOnlyList<NotificationListDto> NotificationLists,
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
IReadOnlyList<ApiKeyDto> ApiKeys,
IReadOnlyList<ApiMethodDto> ApiMethods);
/// <summary>
/// Carved-off secret values for an entity. The outer DTO carries all non-
/// sensitive fields; secrets land here so a future "share without secrets"
/// export mode can drop this block without touching anything else.
/// </summary>
public sealed record SecretsBlock(IReadOnlyDictionary<string, string> Values);
public sealed record TemplateFolderDto(
string Name,
string? ParentName,
int SortOrder);
public sealed record TemplateDto(
string Name,
string? FolderName,
string? BaseTemplateName,
string? Description,
IReadOnlyList<TemplateAttributeDto> Attributes,
IReadOnlyList<TemplateAlarmDto> Alarms,
IReadOnlyList<TemplateScriptDto> Scripts,
IReadOnlyList<TemplateCompositionDto> Compositions);
public sealed record TemplateAttributeDto(
string Name,
string? Value,
DataType DataType,
bool IsLocked,
string? Description,
string? DataSourceReference);
public sealed record TemplateAlarmDto(
string Name,
string? Description,
int PriorityLevel,
AlarmTriggerType TriggerType,
string? TriggerConfiguration,
bool IsLocked);
public sealed record TemplateScriptDto(
string Name,
string Code,
string? TriggerType,
string? TriggerConfiguration,
string? ParameterDefinitions,
string? ReturnDefinition,
bool IsLocked);
public sealed record TemplateCompositionDto(
string InstanceName,
string ComposedTemplateName);
public sealed record SharedScriptDto(
string Name,
string Code,
string? ParameterDefinitions,
string? ReturnDefinition);
public sealed record ExternalSystemDto(
string Name,
string BaseUrl,
string AuthType,
IReadOnlyList<ExternalSystemMethodDto> Methods,
SecretsBlock? Secrets);
public sealed record ExternalSystemMethodDto(
string Name,
string HttpMethod,
string Path,
string? ParameterDefinitions,
string? ReturnDefinition);
public sealed record DatabaseConnectionDto(
string Name,
int MaxRetries,
TimeSpan RetryDelay,
SecretsBlock? Secrets);
public sealed record NotificationListDto(
string Name,
NotificationType Type,
IReadOnlyList<NotificationRecipientDto> Recipients);
public sealed record NotificationRecipientDto(
string Name,
string EmailAddress);
public sealed record SmtpConfigDto(
string Host,
int Port,
string AuthType,
string FromAddress,
string? TlsMode,
int ConnectionTimeoutSeconds,
int MaxConcurrentConnections,
int MaxRetries,
TimeSpan RetryDelay,
SecretsBlock? Secrets);
public sealed record ApiKeyDto(
string Name,
string KeyHash,
bool IsEnabled,
SecretsBlock? Secrets);
public sealed record ApiMethodDto(
string Name,
string Script,
string? ApprovedApiKeyIds,
string? ParameterDefinitions,
string? ReturnDefinition,
int TimeoutSeconds);

View File

@@ -0,0 +1,344 @@
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
namespace ScadaLink.Transport.Serialization;
/// <summary>
/// Converts between the persistence-shaped <see cref="EntityAggregate"/> and
/// the wire-shaped <see cref="BundleContentDto"/>. The conversion strips EF
/// identity (FK ints) and references inter-entity links by name so bundles
/// remain portable across environments. Secrets are carved into a per-entity
/// <see cref="SecretsBlock"/> so the bundle's secret-free public surface can
/// be inspected (or, in future, exported without secrets) without touching the
/// rest of the payload.
/// </summary>
public sealed class EntitySerializer
{
public BundleContentDto ToBundleContent(EntityAggregate aggregate)
{
ArgumentNullException.ThrowIfNull(aggregate);
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
return new BundleContentDto(
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
Name: f.Name,
ParentName: f.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var pname) ? pname : null,
SortOrder: f.SortOrder)).ToList(),
Templates: aggregate.Templates.Select(t => new TemplateDto(
Name: t.Name,
FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
Description: t.Description,
Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
Name: a.Name,
Value: a.Value,
DataType: a.DataType,
IsLocked: a.IsLocked,
Description: a.Description,
DataSourceReference: a.DataSourceReference)).ToList(),
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
Name: a.Name,
Description: a.Description,
PriorityLevel: a.PriorityLevel,
TriggerType: a.TriggerType,
TriggerConfiguration: a.TriggerConfiguration,
IsLocked: a.IsLocked)).ToList(),
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
Name: s.Name,
Code: s.Code,
TriggerType: s.TriggerType,
TriggerConfiguration: s.TriggerConfiguration,
ParameterDefinitions: s.ParameterDefinitions,
ReturnDefinition: s.ReturnDefinition,
IsLocked: s.IsLocked)).ToList(),
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
InstanceName: c.InstanceName,
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())).ToList(),
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
Name: s.Name,
Code: s.Code,
ParameterDefinitions: s.ParameterDefinitions,
ReturnDefinition: s.ReturnDefinition)).ToList(),
ExternalSystems: aggregate.ExternalSystems.Select(sys =>
{
var methods = aggregate.ExternalSystemMethods
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
.Select(m => new ExternalSystemMethodDto(
Name: m.Name,
HttpMethod: m.HttpMethod,
Path: m.Path,
ParameterDefinitions: m.ParameterDefinitions,
ReturnDefinition: m.ReturnDefinition))
.ToList();
SecretsBlock? secrets = null;
if (!string.IsNullOrEmpty(sys.AuthConfiguration))
{
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
{
["AuthConfiguration"] = sys.AuthConfiguration,
});
}
return new ExternalSystemDto(
Name: sys.Name,
BaseUrl: sys.EndpointUrl,
AuthType: sys.AuthType,
Methods: methods,
Secrets: secrets);
}).ToList(),
DatabaseConnections: aggregate.DatabaseConnections.Select(dc => new DatabaseConnectionDto(
Name: dc.Name,
MaxRetries: dc.MaxRetries,
RetryDelay: dc.RetryDelay,
// Connection strings typically embed credentials inline; treat the entire
// string as secret rather than try to parse out the password fragment.
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
{
["ConnectionString"] = dc.ConnectionString,
}))).ToList(),
NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto(
Name: nl.Name,
Type: nl.Type,
Recipients: nl.Recipients.Select(r => new NotificationRecipientDto(
Name: r.Name,
EmailAddress: r.EmailAddress)).ToList())).ToList(),
SmtpConfigs: aggregate.SmtpConfigurations.Select(smtp =>
{
SecretsBlock? secrets = null;
if (!string.IsNullOrEmpty(smtp.Credentials))
{
secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
{
["Credentials"] = smtp.Credentials,
});
}
return new SmtpConfigDto(
Host: smtp.Host,
Port: smtp.Port,
AuthType: smtp.AuthType,
FromAddress: smtp.FromAddress,
TlsMode: smtp.TlsMode,
ConnectionTimeoutSeconds: smtp.ConnectionTimeoutSeconds,
MaxConcurrentConnections: smtp.MaxConcurrentConnections,
MaxRetries: smtp.MaxRetries,
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(),
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());
}
public EntityAggregate FromBundleContent(BundleContentDto content)
{
ArgumentNullException.ThrowIfNull(content);
// Folders: assign synthetic ids by ordinal, resolve parent pointers by name.
var folders = content.TemplateFolders
.Select((dto, ix) => new TemplateFolder(dto.Name) { Id = ix + 1, SortOrder = dto.SortOrder })
.ToList();
var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
for (var i = 0; i < folders.Count; i++)
{
var parentName = content.TemplateFolders[i].ParentName;
if (parentName is not null && folderIdByName.TryGetValue(parentName, out var parentId))
{
folders[i].ParentFolderId = parentId;
}
}
// Templates: same pattern — assign ids, second pass resolves base + folder.
var templates = content.Templates
.Select((dto, ix) =>
{
var t = new Template(dto.Name) { Id = ix + 1, Description = dto.Description };
foreach (var a in dto.Attributes)
{
t.Attributes.Add(new TemplateAttribute(a.Name)
{
TemplateId = t.Id,
Value = a.Value,
DataType = a.DataType,
IsLocked = a.IsLocked,
Description = a.Description,
DataSourceReference = a.DataSourceReference,
});
}
foreach (var al in dto.Alarms)
{
t.Alarms.Add(new TemplateAlarm(al.Name)
{
TemplateId = t.Id,
Description = al.Description,
PriorityLevel = al.PriorityLevel,
TriggerType = al.TriggerType,
TriggerConfiguration = al.TriggerConfiguration,
IsLocked = al.IsLocked,
});
}
foreach (var s in dto.Scripts)
{
t.Scripts.Add(new TemplateScript(s.Name, s.Code)
{
TemplateId = t.Id,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
IsLocked = s.IsLocked,
});
}
return t;
})
.ToList();
var templateIdByName = templates.ToDictionary(t => t.Name, t => t.Id, StringComparer.Ordinal);
for (var i = 0; i < templates.Count; i++)
{
var dto = content.Templates[i];
if (dto.FolderName is not null && folderIdByName.TryGetValue(dto.FolderName, out var fid))
{
templates[i].FolderId = fid;
}
if (dto.BaseTemplateName is not null && templateIdByName.TryGetValue(dto.BaseTemplateName, out var btid))
{
templates[i].ParentTemplateId = btid;
}
foreach (var compDto in dto.Compositions)
{
var comp = new TemplateComposition(compDto.InstanceName)
{
TemplateId = templates[i].Id,
};
if (templateIdByName.TryGetValue(compDto.ComposedTemplateName, out var ctid))
{
comp.ComposedTemplateId = ctid;
}
templates[i].Compositions.Add(comp);
}
}
var sharedScripts = content.SharedScripts
.Select((dto, ix) => new SharedScript(dto.Name, dto.Code)
{
Id = ix + 1,
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
})
.ToList();
var externalSystems = new List<ExternalSystemDefinition>();
var externalSystemMethods = new List<ExternalSystemMethod>();
for (var ix = 0; ix < content.ExternalSystems.Count; ix++)
{
var dto = content.ExternalSystems[ix];
var sys = new ExternalSystemDefinition(dto.Name, dto.BaseUrl, dto.AuthType)
{
Id = ix + 1,
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
};
externalSystems.Add(sys);
foreach (var m in dto.Methods)
{
externalSystemMethods.Add(new ExternalSystemMethod(m.Name, m.HttpMethod, m.Path)
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = m.ParameterDefinitions,
ReturnDefinition = m.ReturnDefinition,
});
}
}
var databaseConnections = content.DatabaseConnections
.Select((dto, ix) =>
{
var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
return new DatabaseConnectionDefinition(dto.Name, connStr)
{
Id = ix + 1,
MaxRetries = dto.MaxRetries,
RetryDelay = dto.RetryDelay,
};
})
.ToList();
var notificationLists = content.NotificationLists
.Select((dto, ix) =>
{
var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type };
foreach (var r in dto.Recipients)
{
list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress)
{
NotificationListId = list.Id,
});
}
return list;
})
.ToList();
var smtpConfigurations = content.SmtpConfigs
.Select((dto, ix) => new SmtpConfiguration(dto.Host, dto.AuthType, dto.FromAddress)
{
Id = ix + 1,
Port = dto.Port,
Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null,
TlsMode = dto.TlsMode,
ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
MaxConcurrentConnections = dto.MaxConcurrentConnections,
MaxRetries = dto.MaxRetries,
RetryDelay = dto.RetryDelay,
})
.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();
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,
})
.ToList();
return new EntityAggregate(
TemplateFolders: folders,
Templates: templates,
SharedScripts: sharedScripts,
ExternalSystems: externalSystems,
ExternalSystemMethods: externalSystemMethods,
DatabaseConnections: databaseConnections,
NotificationLists: notificationLists,
SmtpConfigurations: smtpConfigurations,
ApiKeys: apiKeys,
ApiMethods: apiMethods);
}
}