diff --git a/Directory.Packages.props b/Directory.Packages.props index d8992807..27e946f0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -187,10 +187,10 @@ - - - - + + + + diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index c4c69027..91218454 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -118,16 +118,22 @@ name: otopcua-dev # observable quickly during dev exercise. Each node keeps its OWN local SQLite store; # replication syncs them. # -# SqlitePath is overridden onto the node's existing per-node `/app/data` volume (the one -# LocalDb already uses) instead of the appsettings default `otopcua-secrets.db`, which -# resolves to /app — inside the image's writable container layer. Two things that costs, -# both of which bit the 2026-08-07 pair-local gate: the store is DESTROYED by any -# `up -d --build` container recreate (so a seeded row cannot outlive a rig rebuild), and -# it is unreachable from the host, so seeding or inspecting a node's store meant stopping -# the container and `docker cp`-ing the file out. On the volume, each node still has a -# private store (one volume per node — this is NOT shared storage, and replication is -# still what syncs them), but it survives recreate and a helper container can mount it -# read/write while the node keeps running. +# SqlitePath is overridden onto the node's existing per-node volume instead of the +# in-container default. Two things a container-layer path costs, both of which bit the +# 2026-08-07 pair-local gate: the store is DESTROYED by any `up -d --build` container +# recreate (so a seeded row cannot outlive a rig rebuild), and it is unreachable from the +# host, so seeding or inspecting a node's store meant stopping the container and +# `docker cp`-ing the file out. On the volume, each node still has a private store (one +# volume per node — this is NOT shared storage, and replication is still what syncs +# them), but it survives recreate and a helper container can mount it read/write while +# the node keeps running. +# +# The path is /data (a SECOND mount of the same per-node volume), not the volume's +# /app/data mount: Secrets 0.6.0 fail-closed validation rejects a store inside the +# application content root (/app) — the rule that exists because a deploy renaming the +# app directory took the MxGateway credential store with it (2026-08-09). Same volume, +# same file at the volume root, different mount point — existing seeded stores carry +# over untouched. LocalDb deliberately stays on /app/data (the rule is secrets-only). # # Per-cluster mesh Phase 6: this DPS topic now rides each pair's OWN 2-node mesh, not one # shared six-node mesh — so secret replication is PAIR-LOCAL. central-1/central-2 replicate @@ -138,7 +144,7 @@ name: otopcua-dev # was needed for this — Enabled=true + the same KEK on every node is still correct; it just now # converges within three independent 2-node groups instead of one six-node group. x-secrets-env: &secrets-env - Secrets__SqlitePath: "/app/data/otopcua-secrets.db" + Secrets__SqlitePath: "/data/otopcua-secrets.db" Secrets__Replication__Enabled: "true" Secrets__Replication__AnnounceInterval: "00:00:05" ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}" @@ -429,6 +435,7 @@ services: - "4840:4840" volumes: - otopcua-localdb-central-1:/app/data + - otopcua-localdb-central-1:/data central-2: <<: *otopcua-host @@ -538,6 +545,7 @@ services: - "4841:4840" volumes: - otopcua-localdb-central-2:/app/data + - otopcua-localdb-central-2:/data # ── Site A cluster (2-node driver-only) ───────────────────────────────────── # Per-cluster mesh Phase 6: site-a-1 and site-a-2 form their OWN 2-node "SITE-A" Akka mesh, @@ -659,6 +667,7 @@ services: - "4842:4840" volumes: - otopcua-localdb-site-a-1:/app/data + - otopcua-localdb-site-a-1:/data site-a-2: <<: *otopcua-host @@ -753,6 +762,7 @@ services: - "4843:4840" volumes: - otopcua-localdb-site-a-2:/app/data + - otopcua-localdb-site-a-2:/data # ── Site B cluster (2-node driver-only) ───────────────────────────────────── # Per-cluster mesh Phase 6: site-b-1 and site-b-2 form their OWN 2-node "SITE-B" Akka mesh, @@ -824,6 +834,7 @@ services: - "4844:4840" volumes: - otopcua-localdb-site-b-1:/app/data + - otopcua-localdb-site-b-1:/data site-b-2: <<: *otopcua-host @@ -891,6 +902,7 @@ services: - "4845:4840" volumes: - otopcua-localdb-site-b-2:/app/data + - otopcua-localdb-site-b-2:/data traefik: image: traefik:v3.1 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index f7f66d82..12eeee6b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -100,9 +100,14 @@ if (roleSuffix is not null) // it also migrates the secrets SQLite store on startup. // ASP0000: DELIBERATE throwaway container — expansion must run before builder.Build(), so it // cannot use the app's DI. Fully disposed here; shares no singletons with the host container. +// The content root is passed EXPLICITLY (Secrets 0.6.1 four-argument overload): this throwaway +// container has no IHostEnvironment, and without the explicit root the under-content-root store +// rule is skipped at exactly the moment the migrator creates the store file — a rejected path +// would get an empty database written to it here before the real host aborts a second later +// (the 0.6.0 defect HistorianGateway's adoption found). Pinned by a Program.cs source-scan test. #pragma warning disable ASP0000 await using (var secretsProvider = new ServiceCollection() - .AddZbSecrets(builder.Configuration, "Secrets") + .AddZbSecrets(builder.Configuration, "Secrets", builder.Environment.ContentRootPath) .BuildServiceProvider()) #pragma warning restore ASP0000 { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json index f5e98869..87b177bd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json @@ -12,7 +12,6 @@ } }, "Secrets": { - "SqlitePath": "otopcua-secrets.db", "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsStorePathValidationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsStorePathValidationTests.cs new file mode 100644 index 00000000..f8a57f94 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/SecretsStorePathValidationTests.cs @@ -0,0 +1,152 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; +using ZB.MOM.WW.Secrets; +using ZB.MOM.WW.Secrets.DependencyInjection; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// Positive-control probes of the Secrets 0.6.x fail-closed store-path validation as composed by +/// THIS host — a clean boot is equally consistent with the validator never being wired, so the +/// load-bearing assertions here are the rejections (the approach that caught the library's own +/// 0.6.0 pre-host hole during the family adoption). Covers both compositions the host has: +/// +/// in-host via AddOtOpcUaSecrets (an supplies the +/// content root); +/// the pre-host ${secret:} expander shape from Program.cs — a bare +/// with NO , where the content +/// root must be passed explicitly or the under-content-root rule silently vanishes at exactly +/// the moment the migrator creates the store file (the 0.6.0 defect). +/// +/// +public sealed class SecretsStorePathValidationTests : IDisposable +{ + private readonly string _contentRoot = + Directory.CreateTempSubdirectory("otopcua-secrets-croot-").FullName; + + public void Dispose() + { + try { Directory.Delete(_contentRoot, recursive: true); } catch { /* best effort */ } + } + + private static IConfiguration ConfigWithPath(string sqlitePath) => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = sqlitePath, + ["Secrets:MasterKey:Source"] = "Environment", + ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY", + ["Secrets:RunMigrationsOnStartup"] = "false", + }).Build(); + + private ServiceProvider BuildHostShapedProvider(string sqlitePath) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new FakeHostEnvironment(_contentRoot)); + services.AddOtOpcUaSecrets(ConfigWithPath(sqlitePath)); + return services.BuildServiceProvider(); + } + + [Fact] + public void A_relative_store_path_is_rejected() + { + using var sp = BuildHostShapedProvider("otopcua-secrets.db"); + + var ex = Should.Throw( + () => sp.GetRequiredService>().Value); + + ex.Message.ShouldContain("absolute", Case.Insensitive, + "the rejection must name the rule, or the operator who caused it cannot act on it"); + } + + [Fact] + public void An_absolute_path_inside_the_content_root_is_rejected() + { + // Absolute does NOT imply safe — this is the MxGateway outage shape: the path that lost + // the credential store WAS absolute; it sat in the directory the deploy renames. + var inside = Path.Combine(_contentRoot, "data", "otopcua-secrets.db"); + using var sp = BuildHostShapedProvider(inside); + + var ex = Should.Throw( + () => sp.GetRequiredService>().Value); + + ex.Message.ShouldContain("content root", Case.Insensitive); + File.Exists(inside).ShouldBeFalse("a rejected path must never gain a store file"); + } + + [Fact] + public void An_absolute_path_outside_the_content_root_passes() + { + // The control for the two rejections above: proves the failures fail because of the RULES, + // not because the whole subsystem is unresolvable in this composition. + var outside = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"); + using var sp = BuildHostShapedProvider(outside); + + sp.GetRequiredService>().Value.SqlitePath.ShouldBe(outside); + } + + [Fact] + public async Task The_prehost_expander_rejects_an_in_content_root_path_before_creating_the_store() + { + // Mirrors Program.cs's ${secret:} expansion container byte-for-byte: bare ServiceCollection, + // no IHostEnvironment, content root passed EXPLICITLY (the 0.6.1 four-argument overload), + // then RUN the migration — resolving the migrator alone never touches the filesystem, so a + // resolve-only probe leaves the file assertions vacuous. The rejection must land BEFORE + // creation: an empty store with -wal/-shm at the rejected path is the artifact that made + // the MxGateway outage read as "the database is there, it's just empty". + var inside = Path.Combine(_contentRoot, "data", "otopcua-secrets.db"); + await using var sp = new ServiceCollection() + .AddZbSecrets(ConfigWithPath(inside), "Secrets", _contentRoot) + .BuildServiceProvider(); + + // Record rather than Should.Throw, so the ARTIFACT is asserted first: a regression that + // stops the throw but still writes the store must fail on the file — which is the defect — + // not on a missing exception that never mentions the database at the rejected path. + var ex = await Record.ExceptionAsync(() => + sp.GetRequiredService() + .MigrateAsync(TestContext.Current.CancellationToken)); + + File.Exists(inside).ShouldBeFalse("a rejected path must not be left holding a store file"); + File.Exists(inside + "-wal").ShouldBeFalse(); + File.Exists(inside + "-shm").ShouldBeFalse(); + ex.ShouldBeOfType(); + } + + [Fact] + public async Task Without_the_explicit_content_root_the_prehost_hole_reopens() + { + // Falsification control for the test above, and the reason Program.cs's four-argument call + // is load-bearing: the same composition WITHOUT the explicit root has no IHostEnvironment + // to consult, the under-content-root rule is skipped, and the migration WRITES the store at + // the rejected path. Running the migration (not just resolving) is what makes this a true + // control — it proves the file assertions above are capable of failing. If the library ever + // closes this differently (making this test fail), the source-scan pin on Program.cs can be + // retired with it. + var inside = Path.Combine(_contentRoot, "data", "otopcua-secrets.db"); + await using var sp = new ServiceCollection() + .AddZbSecrets(ConfigWithPath(inside), "Secrets") + .BuildServiceProvider(); + + await sp.GetRequiredService() + .MigrateAsync(TestContext.Current.CancellationToken); + + File.Exists(inside).ShouldBeTrue( + "the 3-argument overload with no IHostEnvironment must create the store at the " + + "in-content-root path — the exact hole the explicit content root closes"); + } + + private sealed class FakeHostEnvironment(string contentRoot) : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Production"; + public string ApplicationName { get; set; } = "OtOpcUa.Tests"; + public string ContentRootPath { get; set; } = contentRoot; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/SecretsPreHostContentRootSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/SecretsPreHostContentRootSourceTests.cs new file mode 100644 index 00000000..22439218 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/SecretsPreHostContentRootSourceTests.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; + +/// +/// Pins the one AddZbSecrets call that no executable test can reach: the pre-host +/// ${secret:} expander in Program.cs (top-level statements). That call composes the +/// secrets subsystem in a throwaway ServiceCollection with no IHostEnvironment, so +/// it MUST pass the content root explicitly (the Secrets 0.6.1 four-argument overload) — dropped +/// back to the three-argument form, the under-content-root store rule silently vanishes at +/// exactly the moment the migrator creates the store file, and a rejected path gains an empty +/// database before the real host aborts (the 0.6.0 defect; the runtime half of this pin is +/// SecretsStorePathValidationTests.Without_the_explicit_content_root_the_prehost_hole_reopens +/// in Host.IntegrationTests). +/// +public sealed class SecretsPreHostContentRootSourceTests +{ + [Fact] + public void The_prehost_expander_passes_the_content_root_explicitly() + { + var source = ReadProgramSource(); + + // The throwaway container's AddZbSecrets call, allowing for formatting drift but requiring + // the third argument to be the builder's content root. + Regex.IsMatch( + source, + @"new\s+ServiceCollection\(\)\s*\.\s*AddZbSecrets\(\s*builder\.Configuration\s*,\s*""Secrets""\s*,\s*builder\.Environment\.ContentRootPath\s*\)", + RegexOptions.Singleline) + .ShouldBeTrue( + "Program.cs's pre-host secrets expander must call the four-argument AddZbSecrets " + + "overload with builder.Environment.ContentRootPath — the throwaway container has no " + + "IHostEnvironment, and without the explicit root the under-content-root store rule " + + "is skipped while the migrator creates the store file"); + } + + private static string ReadProgramSource() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent!; + dir.ShouldNotBeNull(); + + return File.ReadAllText(Path.Combine( + dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host", "Program.cs")); + } +}