feat(secrets): scaffold ZB.MOM.WW.Secrets.Replicator.Grpc + secrets_hub.v1 pull-only proto
Adds the third replicator sibling next to Replicator.SqlServer and Replicator.AkkaDotNet: the central-hosted gRPC secrets hub. This commit is scaffold only -- project, wire contract, solution wiring -- no service or client implementation. The proto mirrors exactly the read half of ISecretReplicationHub (GetManifestAsync, GetManyAsync). ApplyReplicatedAsync is deliberately NOT mirrored: in this topology every update originates at central and a site node is a strict follower, so the narrow contract IS the enforcement -- with no write RPC on the wire, a mis-wired or compromised site node has no way to push a row into the hub regardless of what its authorization allows. Messages are hand-written mirrors of StoredSecret and SecretManifestEntry rather than serializations of the domain types, following the Akka replicator's SecretRowDto precedent: the trust boundary stays structural, and a field added to StoredSecret later cannot start crossing the network merely by existing. Timestamps are ISO-8601 "O" strings (not google.protobuf. Timestamp, which cannot round-trip a DateTimeOffset's offset) so a relayed row is byte-identical to the one central holds; nullable .NET fields use proto3 explicit presence so null and empty string stay distinguishable. Grpc 2.76.0 / Google.Protobuf 3.34.1 match what ScadaBridge, OtOpcUa and the sibling ZB.MOM.WW.LocalDb libraries already resolve, so a consumer taking both gets one Grpc rather than a downgrade warning. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -30,6 +30,17 @@
|
||||
<PackageVersion Include="Akka.Remote" Version="1.5.62" />
|
||||
<PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||
|
||||
<!-- gRPC secrets hub (ZB.MOM.WW.Secrets.Replicator.Grpc).
|
||||
2.76.0 / 3.34.1 is the version the whole family already resolves — ScadaBridge and
|
||||
OtOpcUa pin exactly these, and the sibling ZB.MOM.WW.LocalDb libraries floor them there.
|
||||
A consumer taking the hub alongside LocalDb replication must not be pushed into a
|
||||
downgrade warning or an unplanned Grpc bump just to adopt secret replication. -->
|
||||
<PackageVersion Include="Google.Protobuf" Version="3.34.1" />
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Core.Api" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Net.Client" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
|
||||
|
||||
<!-- Cryptography -->
|
||||
<!-- 9.0.4, not 9.0.0: Microsoft.Data.SqlClient 6.0.2 pulls System.Configuration.ConfigurationManager
|
||||
9.0.4, which requires ProtectedData >= 9.0.4. With transitive pinning on, the lower central
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Replicator.SqlServer/ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Replicator.Grpc/ZB.MOM.WW.Secrets.Replicator.Grpc.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package zb.mom.ww.secrets.hub.v1;
|
||||
|
||||
option csharp_namespace = "ZB.MOM.WW.Secrets.Replicator.Grpc.V1";
|
||||
|
||||
// The central-hosted secrets hub, as seen by a site node.
|
||||
//
|
||||
// PULL-ONLY BY CONSTRUCTION. This service mirrors exactly the read half of
|
||||
// ISecretReplicationHub — GetManifestAsync and GetManyAsync. Its third method,
|
||||
// ApplyReplicatedAsync, is deliberately NOT mirrored: in this topology every update
|
||||
// originates at central, and a site node is a strict follower. The narrow contract IS the
|
||||
// enforcement — with no write RPC on the wire, a mis-wired or compromised site node has no
|
||||
// way to push a row into the hub, regardless of what its authorization happens to allow.
|
||||
// Adding a third RPC to this service would silently retire that guarantee, so do not.
|
||||
//
|
||||
// Everything crossing this boundary is ciphertext or metadata about ciphertext. The
|
||||
// key-encryption key (KEK) never travels: SecretRow carries the KEK's *identifier* and the
|
||||
// DEK *wrapped* by it, and every node in a replicating deployment is configured with the
|
||||
// same KEK out of band.
|
||||
service SecretsHub {
|
||||
// Returns the hub's full manifest — the compact per-secret state (name, revision,
|
||||
// timestamp, tombstone flag) a follower needs to work out which rows it is missing or
|
||||
// behind on, without transferring any ciphertext.
|
||||
rpc GetManifest(GetManifestRequest) returns (GetManifestResponse);
|
||||
|
||||
// Fetches the named encrypted rows in bulk. Names absent from the hub are omitted from
|
||||
// the response rather than reported as errors — the caller's manifest may be a moment
|
||||
// stale, and a row deleted between the two calls is not a fault.
|
||||
rpc GetSecrets(GetSecretsRequest) returns (GetSecretsResponse);
|
||||
}
|
||||
|
||||
// Request for the hub's full manifest. Empty today; a message rather than
|
||||
// google.protobuf.Empty so filters (a name prefix, a revision floor) can be added later
|
||||
// without a breaking signature change.
|
||||
message GetManifestRequest {
|
||||
}
|
||||
|
||||
message GetManifestResponse {
|
||||
repeated ManifestEntry entries = 1;
|
||||
}
|
||||
|
||||
message GetSecretsRequest {
|
||||
// Normalized secret names to fetch. Each is re-validated as a SecretName on the far
|
||||
// side; the wire type is a plain string because SecretName is a validated wrapper, not
|
||||
// a distinct serialized shape.
|
||||
repeated string names = 1;
|
||||
}
|
||||
|
||||
message GetSecretsResponse {
|
||||
repeated SecretRow secrets = 1;
|
||||
}
|
||||
|
||||
// The wire form of ZB.MOM.WW.Secrets.Abstractions.SecretManifestEntry.
|
||||
message ManifestEntry {
|
||||
// SecretManifestEntry.Name (SecretName.Value).
|
||||
string name = 1;
|
||||
|
||||
// SecretManifestEntry.Revision.
|
||||
int64 revision = 2;
|
||||
|
||||
// SecretManifestEntry.UpdatedUtc, as a round-trip ISO-8601 ("O") string.
|
||||
string updated_utc = 3;
|
||||
|
||||
// SecretManifestEntry.IsDeleted.
|
||||
bool is_deleted = 4;
|
||||
}
|
||||
|
||||
// The wire form of ZB.MOM.WW.Secrets.Abstractions.StoredSecret — every field, nothing more.
|
||||
//
|
||||
// This is a hand-written mirror rather than a serialization of the domain type, for the
|
||||
// same reason the Akka replicator's SecretRowDto is: it makes the trust boundary
|
||||
// structural. The whole payload is visible as a flat set of primitives that a reader can
|
||||
// check against "ciphertext only, never the KEK" at a glance, and a field added to
|
||||
// StoredSecret in future cannot start crossing the network merely by existing.
|
||||
//
|
||||
// TIMESTAMPS are ISO-8601 round-trip ("O") strings, not google.protobuf.Timestamp. Two
|
||||
// reasons. (1) Losslessness: DateTimeOffset carries an offset and 100 ns ticks;
|
||||
// Timestamp is a UTC instant, so a non-UTC DateTimeOffset does not round-trip through it
|
||||
// unchanged. (2) Consistency: the Akka replicator's DTO already made this choice, and the
|
||||
// stores persist the same "O" form — one representation family-wide means a row relayed
|
||||
// hub -> site is byte-identical to the row central holds, which the live gates assert.
|
||||
//
|
||||
// NULLABLE .NET fields use proto3 explicit presence (`optional`) so that null and empty
|
||||
// string stay distinguishable end to end; a plain proto3 string would collapse both to "".
|
||||
message SecretRow {
|
||||
// StoredSecret.Name (SecretName.Value).
|
||||
string name = 1;
|
||||
|
||||
// StoredSecret.Description (nullable).
|
||||
optional string description = 2;
|
||||
|
||||
// StoredSecret.ContentType, as the SecretContentType enum MEMBER NAME. A proto enum was
|
||||
// rejected: proto3 decodes an unknown enum number to the zero value, which here would
|
||||
// silently retype a secret as Text. A name is parsed strictly on the far side, so an
|
||||
// unrecognized content type is rejected at the boundary instead of persisted.
|
||||
string content_type = 3;
|
||||
|
||||
// StoredSecret.Ciphertext — AES-256-GCM ciphertext of the plaintext.
|
||||
bytes ciphertext = 4;
|
||||
|
||||
// StoredSecret.Nonce — nonce sealing `ciphertext`.
|
||||
bytes nonce = 5;
|
||||
|
||||
// StoredSecret.Tag — authentication tag for `ciphertext`.
|
||||
bytes tag = 6;
|
||||
|
||||
// StoredSecret.WrappedDek — the per-secret data key, wrapped by the KEK. The KEK itself
|
||||
// never travels.
|
||||
bytes wrapped_dek = 7;
|
||||
|
||||
// StoredSecret.WrapNonce — nonce used to wrap `wrapped_dek`.
|
||||
bytes wrap_nonce = 8;
|
||||
|
||||
// StoredSecret.WrapTag — authentication tag for `wrapped_dek`.
|
||||
bytes wrap_tag = 9;
|
||||
|
||||
// StoredSecret.KekId — an identifier for the wrapping key, not key material.
|
||||
string kek_id = 10;
|
||||
|
||||
// StoredSecret.Revision — monotonic; the last-writer-wins tie-break.
|
||||
int64 revision = 11;
|
||||
|
||||
// StoredSecret.IsDeleted — tombstone flag.
|
||||
bool is_deleted = 12;
|
||||
|
||||
// StoredSecret.DeletedUtc (nullable), as a round-trip ISO-8601 ("O") string.
|
||||
optional string deleted_utc = 13;
|
||||
|
||||
// StoredSecret.CreatedUtc, as a round-trip ISO-8601 ("O") string.
|
||||
string created_utc = 14;
|
||||
|
||||
// StoredSecret.UpdatedUtc, as a round-trip ISO-8601 ("O") string — the primary
|
||||
// last-writer-wins ordering key, so its representation has to be exact.
|
||||
string updated_utc = 15;
|
||||
|
||||
// StoredSecret.CreatedBy (nullable).
|
||||
optional string created_by = 16;
|
||||
|
||||
// StoredSecret.UpdatedBy (nullable).
|
||||
optional string updated_by = 17;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<RootNamespace>ZB.MOM.WW.Secrets.Replicator.Grpc</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Secrets.Replicator.Grpc</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Central-hosted gRPC secrets hub (pull-only) for the ZB.MOM.WW SCADA family — site nodes follow central, ciphertext-only.</Description>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-secrets</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- One package carries BOTH halves of the contract: the hub service base class hosted at
|
||||
central and the follower client dialled from a site node. GrpcServices="Both" is what
|
||||
makes that possible from a single .proto compile. -->
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos/secrets_hub.proto" GrpcServices="Both" ProtoRoot="Protos" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Grpc.AspNetCore brings the server-side hosting surface (MapGrpcService), which needs the
|
||||
ASP.NET Core shared framework. That framework reference also supplies the whole
|
||||
Microsoft.Extensions.* surface the siblings reference explicitly (DI, Options,
|
||||
Configuration, Hosting, Logging), so this project deliberately carries no PackageReference
|
||||
for them — NuGet reports each as prunable (NU1510), which is an error here. -->
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Secrets\ZB.MOM.WW.Secrets.csproj" />
|
||||
<ProjectReference Include="..\ZB.MOM.WW.Secrets.Abstractions\ZB.MOM.WW.Secrets.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" />
|
||||
<PackageReference Include="Grpc.AspNetCore" />
|
||||
<PackageReference Include="Grpc.Net.Client" />
|
||||
<PackageReference Include="Grpc.Core.Api" />
|
||||
<PackageReference Include="Grpc.Tools">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Replicator.Grpc.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user