feat(artifacts): additive Secrets list + SecretsApplied on the artifact deploy contract (proto field 9)
This commit is contained in:
@@ -5,4 +5,5 @@ public record ArtifactDeploymentResponse(
|
|||||||
string SiteId,
|
string SiteId,
|
||||||
bool Success,
|
bool Success,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
DateTimeOffset Timestamp);
|
DateTimeOffset Timestamp,
|
||||||
|
int? SecretsApplied = null);
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ public record DeployArtifactsCommand(
|
|||||||
IReadOnlyList<NotificationListArtifact>? NotificationLists,
|
IReadOnlyList<NotificationListArtifact>? NotificationLists,
|
||||||
IReadOnlyList<DataConnectionArtifact>? DataConnections,
|
IReadOnlyList<DataConnectionArtifact>? DataConnections,
|
||||||
IReadOnlyList<SmtpConfigurationArtifact>? SmtpConfigurations,
|
IReadOnlyList<SmtpConfigurationArtifact>? SmtpConfigurations,
|
||||||
DateTimeOffset Timestamp);
|
DateTimeOffset Timestamp,
|
||||||
|
IReadOnlyList<SecretArtifact>? Secrets = null);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One secret shipped to a site with its artifacts: the ZB.MOM.WW.Secrets StoredSecret row
|
||||||
|
/// VERBATIM — ciphertext under the fleet's shared KEK, never plaintext. The site applies it
|
||||||
|
/// with ISecretStore.ApplyReplicatedAsync. Design: docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md §3.3.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SecretArtifact(
|
||||||
|
string Name,
|
||||||
|
string ContentType,
|
||||||
|
byte[] Ciphertext,
|
||||||
|
byte[] Nonce,
|
||||||
|
byte[] Tag,
|
||||||
|
byte[] WrappedDek,
|
||||||
|
byte[] WrapNonce,
|
||||||
|
byte[] WrapTag,
|
||||||
|
string KekId,
|
||||||
|
long Revision,
|
||||||
|
DateTimeOffset CreatedUtc,
|
||||||
|
DateTimeOffset UpdatedUtc,
|
||||||
|
string? Description = null);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
||||||
@@ -444,6 +445,13 @@ public static class SiteCommandDtoMapper
|
|||||||
dto.SmtpConfigurations = list;
|
dto.SmtpConfigurations = list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.Secrets is not null)
|
||||||
|
{
|
||||||
|
var list = new SecretArtifactListDto();
|
||||||
|
list.Items.AddRange(command.Secrets.Select(ToProto));
|
||||||
|
dto.Secrets = list;
|
||||||
|
}
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +470,56 @@ public static class SiteCommandDtoMapper
|
|||||||
dto.NotificationLists is null ? null : dto.NotificationLists.Items.Select(FromProto).ToList(),
|
dto.NotificationLists is null ? null : dto.NotificationLists.Items.Select(FromProto).ToList(),
|
||||||
dto.DataConnections is null ? null : dto.DataConnections.Items.Select(FromProto).ToList(),
|
dto.DataConnections is null ? null : dto.DataConnections.Items.Select(FromProto).ToList(),
|
||||||
dto.SmtpConfigurations is null ? null : dto.SmtpConfigurations.Items.Select(FromProto).ToList(),
|
dto.SmtpConfigurations is null ? null : dto.SmtpConfigurations.Items.Select(FromProto).ToList(),
|
||||||
Dto(dto.Timestamp));
|
Dto(dto.Timestamp),
|
||||||
|
dto.Secrets is null ? null : dto.Secrets.Items.Select(FromProto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Projects a <see cref="SecretArtifact"/> onto the wire.</summary>
|
||||||
|
/// <param name="artifact">The artifact to project.</param>
|
||||||
|
/// <returns>The wire message.</returns>
|
||||||
|
public static SecretArtifactDto ToProto(SecretArtifact artifact)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(artifact);
|
||||||
|
|
||||||
|
return new SecretArtifactDto
|
||||||
|
{
|
||||||
|
Name = artifact.Name,
|
||||||
|
ContentType = artifact.ContentType,
|
||||||
|
Ciphertext = ByteString.CopyFrom(artifact.Ciphertext),
|
||||||
|
Nonce = ByteString.CopyFrom(artifact.Nonce),
|
||||||
|
Tag = ByteString.CopyFrom(artifact.Tag),
|
||||||
|
WrappedDek = ByteString.CopyFrom(artifact.WrappedDek),
|
||||||
|
WrapNonce = ByteString.CopyFrom(artifact.WrapNonce),
|
||||||
|
WrapTag = ByteString.CopyFrom(artifact.WrapTag),
|
||||||
|
KekId = artifact.KekId,
|
||||||
|
Revision = artifact.Revision,
|
||||||
|
CreatedUtc = Ts(artifact.CreatedUtc),
|
||||||
|
UpdatedUtc = Ts(artifact.UpdatedUtc),
|
||||||
|
Description = artifact.Description
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rehydrates a <see cref="SecretArtifact"/> from the wire.</summary>
|
||||||
|
/// <param name="dto">The wire message to decode.</param>
|
||||||
|
/// <returns>The decoded artifact.</returns>
|
||||||
|
public static SecretArtifact FromProto(SecretArtifactDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
|
return new SecretArtifact(
|
||||||
|
dto.Name,
|
||||||
|
dto.ContentType,
|
||||||
|
dto.Ciphertext.ToByteArray(),
|
||||||
|
dto.Nonce.ToByteArray(),
|
||||||
|
dto.Tag.ToByteArray(),
|
||||||
|
dto.WrappedDek.ToByteArray(),
|
||||||
|
dto.WrapNonce.ToByteArray(),
|
||||||
|
dto.WrapTag.ToByteArray(),
|
||||||
|
dto.KekId,
|
||||||
|
dto.Revision,
|
||||||
|
Dto(dto.CreatedUtc),
|
||||||
|
Dto(dto.UpdatedUtc),
|
||||||
|
dto.Description);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Projects a <see cref="SharedScriptArtifact"/> onto the wire.</summary>
|
/// <summary>Projects a <see cref="SharedScriptArtifact"/> onto the wire.</summary>
|
||||||
@@ -756,7 +813,7 @@ public static class SiteCommandDtoMapper
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(response);
|
ArgumentNullException.ThrowIfNull(response);
|
||||||
|
|
||||||
return new ArtifactDeploymentResponseDto
|
var dto = new ArtifactDeploymentResponseDto
|
||||||
{
|
{
|
||||||
DeploymentId = response.DeploymentId,
|
DeploymentId = response.DeploymentId,
|
||||||
SiteId = response.SiteId,
|
SiteId = response.SiteId,
|
||||||
@@ -764,6 +821,13 @@ public static class SiteCommandDtoMapper
|
|||||||
ErrorMessage = OrEmpty(response.ErrorMessage),
|
ErrorMessage = OrEmpty(response.ErrorMessage),
|
||||||
Timestamp = Ts(response.Timestamp)
|
Timestamp = Ts(response.Timestamp)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (response.SecretsApplied.HasValue)
|
||||||
|
{
|
||||||
|
dto.SecretsApplied = response.SecretsApplied.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Rehydrates an <see cref="ArtifactDeploymentResponse"/> from the wire.</summary>
|
/// <summary>Rehydrates an <see cref="ArtifactDeploymentResponse"/> from the wire.</summary>
|
||||||
@@ -774,7 +838,8 @@ public static class SiteCommandDtoMapper
|
|||||||
ArgumentNullException.ThrowIfNull(dto);
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
|
||||||
return new ArtifactDeploymentResponse(
|
return new ArtifactDeploymentResponse(
|
||||||
dto.DeploymentId, dto.SiteId, dto.Success, NullIfEmpty(dto.ErrorMessage), Dto(dto.Timestamp));
|
dto.DeploymentId, dto.SiteId, dto.Success, NullIfEmpty(dto.ErrorMessage), Dto(dto.Timestamp),
|
||||||
|
dto.SecretsApplied);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -270,6 +270,25 @@ message SmtpConfigurationArtifactDto {
|
|||||||
// own message makes the null/empty distinction a message-presence question,
|
// own message makes the null/empty distinction a message-presence question,
|
||||||
// which proto3 does model — the same silent-data-loss class the transport
|
// which proto3 does model — the same silent-data-loss class the transport
|
||||||
// round-trip guard caught in PLAN-05 T8.
|
// round-trip guard caught in PLAN-05 T8.
|
||||||
|
// One StoredSecret row, ciphertext verbatim under the fleet's shared KEK — never
|
||||||
|
// plaintext on the wire. Site secrets design §3.3.
|
||||||
|
message SecretArtifactDto {
|
||||||
|
string name = 1;
|
||||||
|
string content_type = 2;
|
||||||
|
bytes ciphertext = 3;
|
||||||
|
bytes nonce = 4;
|
||||||
|
bytes tag = 5;
|
||||||
|
bytes wrapped_dek = 6;
|
||||||
|
bytes wrap_nonce = 7;
|
||||||
|
bytes wrap_tag = 8;
|
||||||
|
string kek_id = 9;
|
||||||
|
int64 revision = 10;
|
||||||
|
google.protobuf.Timestamp created_utc = 11;
|
||||||
|
google.protobuf.Timestamp updated_utc = 12;
|
||||||
|
google.protobuf.StringValue description = 13; // absent => null
|
||||||
|
}
|
||||||
|
message SecretArtifactListDto { repeated SecretArtifactDto items = 1; }
|
||||||
|
|
||||||
message SharedScriptArtifactListDto { repeated SharedScriptArtifactDto items = 1; }
|
message SharedScriptArtifactListDto { repeated SharedScriptArtifactDto items = 1; }
|
||||||
message ExternalSystemArtifactListDto { repeated ExternalSystemArtifactDto items = 1; }
|
message ExternalSystemArtifactListDto { repeated ExternalSystemArtifactDto items = 1; }
|
||||||
message DatabaseConnectionArtifactListDto { repeated DatabaseConnectionArtifactDto items = 1; }
|
message DatabaseConnectionArtifactListDto { repeated DatabaseConnectionArtifactDto items = 1; }
|
||||||
@@ -286,6 +305,7 @@ message DeployArtifactsCommandDto {
|
|||||||
DataConnectionArtifactListDto data_connections = 6; // absent => null
|
DataConnectionArtifactListDto data_connections = 6; // absent => null
|
||||||
SmtpConfigurationArtifactListDto smtp_configurations = 7; // absent => null
|
SmtpConfigurationArtifactListDto smtp_configurations = 7; // absent => null
|
||||||
google.protobuf.Timestamp timestamp = 8;
|
google.protobuf.Timestamp timestamp = 8;
|
||||||
|
SecretArtifactListDto secrets = 9; // absent => null
|
||||||
}
|
}
|
||||||
|
|
||||||
message DeploymentStatusResponseDto {
|
message DeploymentStatusResponseDto {
|
||||||
@@ -319,6 +339,7 @@ message ArtifactDeploymentResponseDto {
|
|||||||
bool success = 3;
|
bool success = 3;
|
||||||
string error_message = 4; // empty string represents null
|
string error_message = 4; // empty string represents null
|
||||||
google.protobuf.Timestamp timestamp = 5;
|
google.protobuf.Timestamp timestamp = 5;
|
||||||
|
google.protobuf.Int32Value secrets_applied = 6; // absent => null
|
||||||
}
|
}
|
||||||
|
|
||||||
message LifecycleRequest {
|
message LifecycleRequest {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+85
@@ -0,0 +1,85 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip tests for the additive <c>Secrets</c> / <c>SecretsApplied</c> wire contract
|
||||||
|
/// on <see cref="DeployArtifactsCommand"/> / <see cref="ArtifactDeploymentResponse"/>
|
||||||
|
/// (proto field 9 / field 6). Site secrets design §3.3, task-2 of
|
||||||
|
/// docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteCommandDtoMapperSecretsTests
|
||||||
|
{
|
||||||
|
private static SecretArtifact Sample(string name) => new(name, "text/plain",
|
||||||
|
[1, 2, 3], [4], [5], [6, 7], [8], [9], "kek-A", 7,
|
||||||
|
DateTimeOffset.Parse("2026-09-01T00:00:00Z"), DateTimeOffset.Parse("2026-09-02T00:00:00Z"), "d");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeployArtifacts_Secrets_RoundTrip_Verbatim()
|
||||||
|
{
|
||||||
|
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null,
|
||||||
|
DateTimeOffset.UtcNow, [Sample("A"), Sample("B")]);
|
||||||
|
var back = (DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(cmd));
|
||||||
|
Assert.Equal(2, back.Secrets!.Count);
|
||||||
|
Assert.Equal(cmd.Secrets![0].Ciphertext, back.Secrets[0].Ciphertext);
|
||||||
|
Assert.Equal(cmd.Secrets[0].Nonce, back.Secrets[0].Nonce);
|
||||||
|
Assert.Equal(cmd.Secrets[0].Tag, back.Secrets[0].Tag);
|
||||||
|
Assert.Equal(cmd.Secrets[0].WrappedDek, back.Secrets[0].WrappedDek);
|
||||||
|
Assert.Equal(cmd.Secrets[0].WrapNonce, back.Secrets[0].WrapNonce);
|
||||||
|
Assert.Equal(cmd.Secrets[0].WrapTag, back.Secrets[0].WrapTag);
|
||||||
|
Assert.Equal(cmd.Secrets[0].ContentType, back.Secrets[0].ContentType);
|
||||||
|
Assert.Equal(cmd.Secrets[0].CreatedUtc, back.Secrets[0].CreatedUtc);
|
||||||
|
Assert.Equal(cmd.Secrets[0].UpdatedUtc, back.Secrets[0].UpdatedUtc);
|
||||||
|
Assert.Equal(cmd.Secrets[0].Description, back.Secrets[0].Description);
|
||||||
|
Assert.Equal("kek-A", back.Secrets[1].KekId);
|
||||||
|
Assert.Equal(7, back.Secrets[1].Revision);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeployArtifacts_NullSecrets_StaysNull()
|
||||||
|
{
|
||||||
|
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null, DateTimeOffset.UtcNow);
|
||||||
|
var dto = SiteCommandDtoMapper.ToProto(cmd);
|
||||||
|
Assert.Null(dto.Secrets);
|
||||||
|
Assert.Null(((DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(dto)).Secrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeployArtifacts_EmptySecrets_StaysEmpty_NotNull()
|
||||||
|
{
|
||||||
|
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null,
|
||||||
|
DateTimeOffset.UtcNow, []);
|
||||||
|
var back = (DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(cmd));
|
||||||
|
Assert.NotNull(back.Secrets);
|
||||||
|
Assert.Empty(back.Secrets!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeployArtifacts_CommandDtoWithoutField9_MapsToNullSecrets()
|
||||||
|
{
|
||||||
|
// Simulates an OLD CENTRAL that never set field 9 — the DTO here carries no
|
||||||
|
// Secrets wrapper at all, exactly as a pre-upgrade sender's wire bytes would
|
||||||
|
// decode. FromProto must treat that absence as null, not an empty list.
|
||||||
|
var dto = new DeployArtifactsCommandDto
|
||||||
|
{
|
||||||
|
DeploymentId = "dep",
|
||||||
|
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(dto.Secrets);
|
||||||
|
|
||||||
|
var back = SiteCommandDtoMapper.FromProto(dto);
|
||||||
|
Assert.Null(back.Secrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData(3)]
|
||||||
|
public void ArtifactResponse_SecretsApplied_RoundTrips(int? applied)
|
||||||
|
{
|
||||||
|
var r = new ArtifactDeploymentResponse("dep", "site-a", true, null, DateTimeOffset.UtcNow, applied);
|
||||||
|
Assert.Equal(applied, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsApplied);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,8 @@ internal static class SiteCommandSamples
|
|||||||
Add(new NotificationListArtifact("ops", ["a@x.com"]), new NotificationListArtifact("empty", []));
|
Add(new NotificationListArtifact("ops", ["a@x.com"]), new NotificationListArtifact("empty", []));
|
||||||
Add(DataConnection(), new DataConnectionArtifact("bare", "OpcUa", null, null, 0));
|
Add(DataConnection(), new DataConnectionArtifact("bare", "OpcUa", null, null, 0));
|
||||||
Add(Smtp(), new SmtpConfigurationArtifact("bare", "smtp", 25, "None", "f@x.com", null, null, null));
|
Add(Smtp(), new SmtpConfigurationArtifact("bare", "smtp", 25, "None", "f@x.com", null, null, null));
|
||||||
|
Add(Secret(), new SecretArtifact(
|
||||||
|
"bare", "text/plain", [], [], [], [], [], [], "kek-bare", 0, T2, T2));
|
||||||
|
|
||||||
// ── Lifecycle replies ──
|
// ── Lifecycle replies ──
|
||||||
Add(new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Success, null, T1),
|
Add(new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Success, null, T1),
|
||||||
@@ -233,6 +235,11 @@ internal static class SiteCommandSamples
|
|||||||
private static SmtpConfigurationArtifact Smtp() =>
|
private static SmtpConfigurationArtifact Smtp() =>
|
||||||
new("Default", "smtp.host", 587, "Basic", "from@x.com", "user", "secret", "{\"tenant\":\"t\"}");
|
new("Default", "smtp.host", 587, "Basic", "from@x.com", "user", "secret", "{\"tenant\":\"t\"}");
|
||||||
|
|
||||||
|
private static SecretArtifact Secret() =>
|
||||||
|
new("EsgApiKey", "application/octet-stream",
|
||||||
|
[1, 2, 3, 4], [5, 6], [7, 8, 9], [10, 11], [12], [13, 14, 15],
|
||||||
|
"kek-2026-09", 4, T1, T2, "MES API key");
|
||||||
|
|
||||||
private static BrowseNode Node() => new("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, false, "Double", -1, true);
|
private static BrowseNode Node() => new("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, false, "Double", -1, true);
|
||||||
|
|
||||||
private static BrowseNode NodeBare() => new("ns=2;s=Root", "Root", BrowseNodeClass.Object, true);
|
private static BrowseNode NodeBare() => new("ns=2;s=Root", "Root", BrowseNodeClass.Object, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user