Commit Graph

712 Commits

Author SHA1 Message Date
Joseph Doherty eab617f741 docs(index): LocalDb 0.3.7 fixes #16; Secrets Replicator.Grpc deprecated (scadaproj#16, #8)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:30:57 -05:00
Joseph Doherty ab1ad689df chore(secrets): deprecate Replicator.Grpc — out of the build and pack (scadaproj#8)
Owner chose option (b). ZB.MOM.WW.Secrets.Replicator.Grpc has had no consumer
since ScadaBridge retired the gRPC secrets hub (2026-09-28); OtOpcUa never used it.
- removed the project and its test project from ZB.MOM.WW.Secrets.slnx, so the
  family build/test and build/push.sh's `dotnet pack` skip it;
- csproj IsPackable=false (a direct pack of the project yields nothing);
- SecretsGrpcHubServerExtensions / SecretsGrpcHubClientExtensions [Obsolete];
- README, CHANGELOG (0.6.6 Deprecated), CLAUDE.md and clustered-secrets.md say so.
Source + tests stay in the tree and still build/run standalone (test csproj
suppresses CS0618): 200/200. Feed versions 0.4.1-0.6.3 are left for the owner
to unlist.

Also zeroes the solution's pre-existing warnings: CA1416 on two Unix-only
permission tests (guarded by Skip.IfNot, which the analyzer cannot see — now
[UnsupportedOSPlatform("windows")]) and two xUnit2031 Assert.Single(Where).
Build 0 warnings; tests 429 passed / 21 skipped / 0 failed; pack = 5 nupkgs.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:30:57 -05:00
Joseph Doherty be5e9fd436 fix(localdb): keep a tombstone until the peer holds it — no resurrection past TombstoneRetention (scadaproj#16)
0.3.7 (unpublished), owner-chosen option A. A tombstone is now pruned only when
it is past TombstoneRetention AND the peer is known to hold it:
- held while database-wide or per-table snapshot debt is outstanding (the
  snapshot is built from the row-version ledger, so a pruned tombstone is a
  delete it can no longer carry — the issue's second path);
- a local tombstone held while its delete is still in the unacked oplog.
Delivery (delta ack covering the delete, or snapshot debt discharged) restamps
tombstone_utc so retention runs from delivery. New optional
LocalDb:Replication:TombstoneHardCap (default off, >= TombstoneRetention)
bounds an undelivered tombstone's life. No proto, lib_schema_version or
on-disk schema change.

Regression test TombstoneResurrectionTests (two real nodes over loopback gRPC):
offline update on B, newer delete on A, A's clock +8 days (oplog age cap +
tombstone prune) -> reconnect. Fails on 0.3.6 (row resurrected), passes now;
plus a no-prune positive control and a pruned-after-delivery control, and five
controlled-clock OplogStore tests. Each guard clause mutation-killed. 259/259.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:27:46 -05:00
Joseph Doherty 3215d51154 docs(auth): record parked constraint-policy + JWT-helper decisions (scadaproj#20)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:13:46 -05:00
Joseph Doherty 5c207d1fe5 docs(index): four apps pin Auth 0.4.1 on their default branches (scadaproj#5)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 18:30:57 -05:00
Joseph Doherty 703461dab1 docs(secrets): OtOpcUa is Akka pair-local, never a gRPC-hub consumer; 0.6.3 has no hub consumer left (scadaproj#8)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 18:20:30 -05:00
Joseph Doherty 4382446ebd docs(index): unpublished shared-lib versions after the 2026-11-09 issue waves; Theme 0.5.0 on all consumer default branches
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:53:34 -05:00
Joseph Doherty 3df31d0c7a feat(theme): opt-in ThemeShell ScrollNav rail scroll pane (ScadaBridge#61)
Upstreams ScadaBridge's local .rail-scroll pane. ScrollNav (default false)
wraps Nav in .rail-scroll and adds side-rail-scroll; layout.css (lg+ only)
fills the rail and scrolls just the pane so .rail-foot stays pinned.
Default markup/CSS unchanged for other consumers. Stays at 0.5.2, unpublished.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:52:57 -05:00
Joseph Doherty 6123d03f92 build(overview): bump ZB.MOM.WW.Theme 0.4.1 -> 0.5.0 (scadaproj#9)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:51:24 -05:00
Joseph Doherty ebc493361e fix(secrets): audit malformed ${secret:} tokens, cap SecretName at 450, resolve local UNC shares (scadaproj#52)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:38:36 -05:00
Joseph Doherty 4a3c4e9b7e docs(secrets): record the P3 batch and #49 in findings, CHANGELOG 0.6.6 and the review index (scadaproj#45)
Secrets-004–009, 011, 012, 014–016, 018 and 019 marked Resolved with commit, fix and regression
evidence; Secrets open findings 13 -> 0; code-reviews/README.md regenerated (--check passes).
CHANGELOG 0.6.6 records the fixes, behaviour changes and the #49 follow-ups.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty fc81cbbd36 test(secrets-cli): CLI rig keeps the store outside the working directory (scadaproj#45)
The CLI host takes its working directory as the content root, and the rig put the store under it.
The old lexical store-path rule missed that on macOS only because the temp path is spelled
/var/... while the working directory reports /private/var/...; with Secrets-005's link resolution
the rule (correctly) refused it. The rig now runs from a sibling work/ directory.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty db441d9d99 docs(secrets): document the plain-IConfigurationRoot reload gap and OtOpcUa's redundant starter (scadaproj#49)
The reload gap for a plain IConfigurationRoot is documented rather than fixed: closing it would mean
inserting a provider into ConfigurationRoot's internal list. No family host is exposed (plain roots
are built with reloadOnChange: false; the rest pass a ConfigurationManager). clustered-secrets.md
notes that AddZbSecretsAkkaReplication now starts the actor itself, so OtOpcUa's
SecretReplicationStarter becomes redundant once it adopts 0.6.6.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty fac3c5355c fix(secrets-grpc): hub-call cancellation surfaces as cancellation, not a failed sweep (scadaproj#45)
Secrets-019: the hub channels are created with ThrowOperationCanceledOnCancellation = true, so a
call cancelled through its token throws OperationCanceledException. Defensively, the failover
reader rethrows RpcException(Cancelled)-with-a-cancelled-token as cancellation instead of dialing
every fallback with the same cancelled token, and the sweep treats it as shutdown, not a failure
recorded in ISecretsHubSyncStatus. A server-side Cancelled status (token not cancelled) still fails
over.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty 3aa7c728d5 fix(secrets-cli): bundle import validates every entry before writing (scadaproj#45)
Secrets-018: BundleService.ImportAsync now rehydrates every entry and re-wraps every foreign-KEK
row before the first write, so a malformed or tampered entry anywhere fails the import with the
store untouched ("Nothing was imported"). A store fault during the apply phase (no multi-row
transaction) now reports the row it stopped at and every row already written, which the
interactive flow prints, instead of a bare "Import failed".

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty 649d4a0616 fix(secrets-ui): components re-check their own policy; pin the real policy matrix (scadaproj#45)
Secrets-016: RevealButton (RevealPolicy), SecretEditor and ConfirmDeleteModal (ManagePolicy) now
evaluate their policy through IAuthorizationService at the top of the revealing/mutating handler
and fail closed (no auth state, no IAuthorizationService, or a failed policy), instead of relying
on the host to wrap them in AuthorizeView. New tests build the REAL AddSecretsAuthorization
policies and evaluate each role against each policy, so secrets-manager satisfying RevealPolicy
would now fail a test.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty a84c64e26f fix(secrets-ui): Reveal fails closed when its audit cannot be written (scadaproj#45)
Secrets-015: RevealButton now writes the secret.reveal Success audit before showing the value and
shows nothing ("audit record could not be written") if that write throws; the failure is logged as
a warning with actor and name, never the value. The cancellation token is captured up front, so a
component disposed mid-reveal can no longer turn the audit into a swallowed ObjectDisposedException.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty fe94b32d42 fix(secrets-ui): Add refuses an existing name; restoring a tombstone needs a second submit (scadaproj#45)
Secrets-014: SecretEditor in add mode reads the name first. A live row is refused ("already
exists. Use Rotate") with no write and no audit, so a pasted name can no longer replace a live
credential and reset its description/content type under a "secret.add" audit. A tombstoned name is
restored only when the operator submits the same name a second time after the warning.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty 849640f467 fix(secrets-akka): announce from the mailbox with a sender; chunk anti-entropy payloads (scadaproj#45)
Secrets-011: HandleAnnounceTick pipes the manifest read back to the actor and publishes the
announce from the mailbox with Self as sender. Under a genuinely async store it used to publish
from a pool continuation with NoSender, so every peer replied to deadLetters; a faulted manifest
read was dropped silently and is now logged.

Secrets-012: the exchange's push rows, the pull reply and the pull request are split into
messages of at most 64 KB (measured with the wire encoding), half Akka.Remote's default 128000-byte
frame, so a cold node converges against a large store. A single over-budget row is still sent
alone; the manifest announce is not split (documented in clustered-secrets.md).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty 5e2d3dd1bc fix(secrets): malformed ${secret:} references fail closed; README sample validates (scadaproj#45)
Secrets-009: SecretReferenceExpander.ExpandAsync throws FormatException when a value holds a
${secret: marker that is not a complete reference with a non-blank name (${secret:}, an
unterminated ${secret:name, a nested marker), instead of passing the literal text through as the
credential. ExpandConfigurationAsync names the offending key (never the value) and writes nothing.
The README wiring sample drops the relative "SqlitePath": "secrets.db" (which failed validation)
for the per-user default and shows an absolute example; a test validates the sample.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty 262e11feaf fix(secrets-cli): shipped appsettings.json no longer sets a relative SqlitePath (scadaproj#49)
The CLI's own appsettings.json set "SqlitePath": "secrets.db", which the store-path validator
rejects as relative, so the headless CLI failed at startup when run from its own directory unless
Secrets__SqlitePath overrode it. The key is removed: the per-user default
(<LocalApplicationData>/ZB.MOM.WW/secrets.db) applies, and an app's appsettings.json in the working
directory or the environment still override it. Regression test validates the shipped file.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty 64efb49996 fix(secrets): hub push sweep isolates each row (scadaproj#45)
Secrets-008: SqlServerSecretSyncService.SweepAsync now catches a per-row push failure, logs it
(EventId 4, name only) and continues, like the pull side. One row the hub refuses (a >450-char
name, an over-long explicit KEK id) no longer stops every later name from reaching the hub.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty 16fac72858 fix(secrets): resolver never re-caches across Invalidate; expired plaintext is evicted (scadaproj#45)
Secrets-006: DefaultSecretResolver keeps a per-key invalidation generation; Invalidate bumps it
before evicting, and a resolve that read the store under an older generation withdraws its insert,
so a rotation mid-resolve is not undone for another TTL. Expired entries are removed on lookup and
by a periodic TimeProvider sweep (every TTL, at least 1 s); a non-positive TTL caches nothing.
The resolver is now IDisposable (stops the sweep timer).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty eff8c6a00e fix(secrets): store-path rules compare locations, not spellings (scadaproj#45)
Secrets-005: AddIfNotRooted now uses Path.IsPathFullyQualified, so Windows drive-relative
(C:secrets.db) and root-relative (\secrets.db) paths are rejected. AddIfUnderContentRoot refuses a
store if EITHER its spelled location (device prefix stripped, loopback admin share mapped to its
drive) OR its resolved location (symlinks/junctions followed, 8.3 names expanded on Windows) is
under the likewise-resolved content root. A store spelled through the content root stays refused
even if a link inside carries it elsewhere. UNC paths through other shares are compared as written.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty b64628de0b fix(secrets): malformed rows fail as SecretDecryptionException; one KEK snapshot per cipher op (scadaproj#45)
Secrets-004: AesGcmEnvelopeCipher.Decrypt now wraps the ArgumentException AesGcm throws for a
wrong-length nonce/tag/wrapped DEK as SecretDecryptionException (as Rewrap already did), so the
resolver's decryption-failed audit fires.

Secrets-007: new MasterKeySnapshot (key + KekId from one reading, zeroed on Dispose) and a
default-implemented IMasterKeyProvider.GetSnapshot(); MasterKeyProviderBase overrides it to
resolve the source once and derive the id from that same key. Encrypt/Decrypt/Rewrap take one
snapshot per KEK and dispose it, so a key file swapped mid-operation can no longer stamp a row
with one key's id and wrap it under another. Intermediate key buffers are zeroed. On-disk format
unchanged: a golden row written by the 0.6.5 cipher still decrypts.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:32:28 -05:00
Joseph Doherty b33f0f93c9 fix(auth): SQL Server key store refuses truncation and matches key ids exactly (Auth-008)
- Over-length key records (key_id > 64, prefix > 16, display name > 256) are
  refused with ArgumentException before connecting; an over-64 key-id lookup
  or mutation answers not-found without querying (SqlClient silently
  truncated the sized parameter, so a different id sharing the first 64
  characters matched).
- Every key_id predicate adds an exact VARBINARY comparison, so lookups are
  case- and trailing-space-exact like SQLite; the PK seek is unchanged and
  there is no schema change.
- SqliteApiKeyStoreImporter reports case-only key-id collisions and
  over-length rows in new init lists (KeyIdCollisions, KeysRejected) instead
  of counting them as skips or importing them truncated.
- Audit text truncation kept deliberately and documented.

Offline refused-port tests and live SQL Server 2022 tests fail pre-fix;
SqlServer suite 101/101 live. Auth findings now 0 open; CLAUDE.md records
the #46/#50 work; code-reviews/README.md regenerated.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:26:03 -05:00
Joseph Doherty c4366539ca fix(auth): ApiKeyVerifier reports store faults as StoreUnavailable (Auth-010)
A key-store lookup that threw (shared SQL Server unreachable, SQLite I/O
fault) escaped VerifyAsync despite the "only exception path is cancellation"
contract, surfacing as an unaudited 500 / gRPC Unknown. ApiKeyFailure gains
StoreUnavailable (appended; numeric values unchanged) and the lookup maps any
non-cancellation exception to it. Unit theory and an offline refused-port
SqlServerApiKeyStore test fail pre-fix.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:21:00 -05:00
Joseph Doherty b8d3fa2ba1 fix(auth): prove the AD domain of a u:DOMAIN\name user bind
Auth-007's sAMAccountName match assumed the search base covers one AD
domain; across a forest (Global Catalog) it could accept another domain's
same-named account and return its groups. For a u:DOMAIN\name Who-am-I
answer the entry must now also be that domain's account: AD's
msDS-PrincipalName (objectSid-derived DOMAIN\name, read by a base search
through the new ILdapConnection.Read) must equal the answer where offered;
otherwise the name must have exactly one holder under SearchBase and it must
be the entry found. Three forest cases fail pre-fix; single-domain and
sAMAccountName-search compat cases pass both ways. README notes this and
Auth-009.

(scadaproj#50)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:18:46 -05:00
Joseph Doherty 9ec0da1b2c fix(auth): canonical username is the attribute value the user matched (Auth-009)
ExtractUserName returned the first value of a possibly multi-valued
UserNameAttribute (default cn), so jsmith could sign in as "John Smith"
depending on server ordering. It now prefers the value equal to the typed
name ignoring case (still canonicalising case) and falls back to the first
value only when none matches.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:18:25 -05:00
Joseph Doherty b9a096c53a docs(localdb): refresh README, record 0.3.6 #46/#50 notes (LocalDb-007)
README: 0.3.6 version heading and changelog entries (0.3.5, 0.3.6), OtOpcUa
Phases 1+2, Synchronous EXTRA, per-table baseline wording in Ops notes, test
count 249. CLAUDE.md records LocalDb-005/006/007 and the #50 items, with the
cross-repo LocalDbSyncAuthInterceptor decision left as a note. LocalDb
findings now 0 open; code-reviews/README.md regenerated.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:13:24 -05:00
Joseph Doherty b2adddbc8e fix(localdb): count snapshot dead-letters and cap held-back snapshot rows
- SnapshotApplier now takes LocalDbMetrics (wired by SyncSessionFactory) and
  adds every row it dead-letters - poison, drift reject, FK orphan, retry
  failure, cap spill - to localdb.sync.dead_lettered. Before, only the delta
  path recorded the metric.
- New ReplicationOptions.MaxSnapshotDeferredRows (default 100,000, validated
  > 0) bounds the rows held for a missing FK parent; past it the oldest get
  one more apply with deferral off and are dead-lettered if still orphaned.
  An older name-ordered sender could otherwise defer a whole child table.

Unit + 2-node (SyncSessionFactory) tests fail pre-fix. No wire change.

(scadaproj#50)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:12:41 -05:00
Joseph Doherty fd46999946 fix(localdb): refuse AUTOINCREMENT keys at RegisterReplicated (LocalDb-005)
Each node assigns its own AUTOINCREMENT keys, so two different rows inserted
on the two nodes share a pk_json and LWW silently keeps one on both nodes.
RegisterReplicated now refuses such a table (detected from SQLite column
metadata, before any trigger is installed). A plain INTEGER PRIMARY KEY is
still accepted (application-assigned ids are legitimate); the requirement
that replicated keys be unique across both nodes is documented in the
RegisterReplicated remarks and README Limitations. No ScadaBridge or OtOpcUa
replicated table is AUTOINCREMENT.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:09:45 -05:00
Joseph Doherty 76e9c355b4 fix(localdb): escape apostrophes in apply/snapshot JSON paths (LocalDb-006)
A column name containing ' was captured fine but ended the JSON-path SQL
literal early on the apply and snapshot paths, dead-lettering every row of the
table on the peer and faulting the snapshot page query for a quoted PK column.
Both paths now share JsonPathSql.For, which doubles ' after the JSON-path
escape. 2-node delta and wiped-peer snapshot tests fail pre-fix.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:08:04 -05:00
Joseph Doherty c207573595 fix(localdb): FailClosedOnDrift applies to snapshot rows (scadaproj#39)
The drift guard lived only in the delta path; SnapshotApplier applied every
row unchecked, so a future-stamped snapshot row was applied, observed by the
clock and persisted as last_seen_hlc - moving the receiver's HLC ahead across
restarts despite FailClosedOnDrift (LocalDb-004). SnapshotApplier now takes
ReplicationOptions and runs the same per-row check (warn once per snapshot;
dead-letter under FailClosedOnDrift via the shared HlcDriftGuard, with the
row's HLC kept out of last_seen_hlc).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:02:17 -05:00
Joseph Doherty 15a6c7c710 fix(localdb): apply upserts on the primary key, not INSERT OR REPLACE (scadaproj#38)
INSERT OR REPLACE resolved a secondary UNIQUE conflict by deleting a
different row (another PK) with capture suppressed and its ledger left live,
so a pair diverged silently (LocalDb-003). The applier now uses
INSERT ... ON CONFLICT(<pk>) DO UPDATE (DO NOTHING when every column is PK):
a clash on another unique key aborts the entry, which is dead-lettered, and
each node keeps its own row. Side effect: a remote update of a parent with
ON DELETE RESTRICT children now applies instead of being dead-lettered.

Also waits for the snapshot watermark in WipedPeer_SnapshotLiftsItsWatermark,
which raced SnapshotComplete under full-suite load.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:02:17 -05:00
Joseph Doherty 4af0ea8152 fix(localdb): snapshot delivers FK-linked rows in any order (scadaproj#37)
The snapshot streamed tables by name and rows by pk_json text, so a child
(or a self-reference whose parent's key sorts later) reached the receiver
first, failed its immediate FK check and was dead-lettered - permanently,
since the snapshot's watermark and ack covered it (LocalDb-002).

Sender: stream referenced tables first (Kahn order, name tie-break).
Receiver: SnapshotApplier defers rows that fail only
SQLITE_CONSTRAINT_FOREIGNKEY, retries them as rows land, and dead-letters
true orphans at SnapshotComplete. Delta path unchanged; wire unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:02:17 -05:00
Joseph Doherty 48813bba6f fix(localdb): sync endpoint enforces the configured ApiKey (scadaproj#36)
LocalDbSyncService never read request headers, so setting
LocalDb:Replication:ApiKey on both nodes left the passive endpoint open
(LocalDb-001). It now requires Authorization: Bearer <key> (constant-time)
before the handshake and before newest-wins takeover, refusing anything else
with Unauthenticated. A blank key keeps the old accept-all behaviour and
logs a startup warning. Wire-compatible: the initiator has always sent the
header when the key is set; ScadaBridge/OtOpcUa interceptors use the same key.

LocalDb 0.3.5 -> 0.3.6 (unpublished).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:02:16 -05:00
Joseph Doherty d549a782fd fix(auth): user bind proves the bound account is the entry whose groups it returns (scadaproj#40)
BindMode=User bound as UserBindTemplate({0}) and then returned the groups of
whatever entry (UserNameAttribute={0}) found - two directory lookups that can
name two accounts (Auth-007). Reproduced live against GLAuth: mallory bound as
herself and signed in with boss's Admins group.

After the bind the service now asks RFC 4532 Who-am-I and requires the entry
to be that account (dn: -> DN match; AD u:DOMAIN\name -> sAMAccountName match).
Without a Who-am-I answer only a DN template is provable (bind DN must equal
the entry DN); anything else fails closed as GroupLookupFailed. {0} is
RFC 4514-escaped in DN templates; validator message leads with EXAMPLE\{0}.

Auth 0.4.3 -> 0.4.4 (unpublished).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:02:16 -05:00
Joseph Doherty 5f5d684c44 fix(secrets): CLI set/rotate read the value from stdin or a masked prompt (scadaproj#35)
`secret set`/`rotate` took the plaintext as a positional argument (shell
history, process list), and Program handed argv to
Host.CreateApplicationBuilder, whose command-line config provider crashed on
a value like "-p=Hunter2Secret" with the value in the unhandled
FormatException on stderr.

- SetCommandLine: `set|rotate <name> [-|--value-stdin]`; no value reads piped
  stdin or prompts twice (masked) at a TTY; one trailing newline dropped;
  empty refused. The positional <value> still works and warns on stderr.
- host built from HostApplicationBuilderSettings without argv; startup
  failures print `secret: startup failed: <msg>` and exit 1
- unexpected trailing arguments reported by position, not echoed
- tests: SetCommandLineTests incl. two child-process runs of the built CLI;
  the -p=Hunter2Secret probe fails (leaks) on the old Program.cs
- docs: README, clustered-secrets runbook, CHANGELOG
- code-reviews: Secrets-017 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00
Joseph Doherty e1487fbf10 fix(secrets): UI reports a committed change as saved when its audit throws (scadaproj#34)
SecretEditor and ConfirmDeleteModal wrote the success audit inside the same
try as the store write, so a throwing IAuditWriter after a committed
add/rotate/delete showed an error, wrote a Failure audit and skipped the
resolver-cache eviction - the old plaintext kept resolving for the TTL.

The mutation try now covers only the cipher + store call; the success audit
is best-effort afterwards and logs a warning (no value) on failure, so the
change is reported saved, the callback fires and the cache is evicted.

- tests: AuditFailureAfterCommitTests (add, rotate, delete) with a throwing
  success audit; all fail without the fix
- code-reviews: Secrets-013 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00
Joseph Doherty 6112a9f447 fix(secrets): Akka replication actor starts with the host (scadaproj#33)
The replication actor was created only when something resolved ISecretStore
(store -> replicator -> SecretReplicationActorProvider.ActorRef). A node that
expanded its ${secret:} references pre-host and never touched the store at
runtime had no actor: no topic subscription, no announces, no pull replies.

AddZbSecretsAkkaReplication now registers SecretReplicationActorStarter, which
creates the actor on IHostApplicationLifetime.ApplicationStarted (after every
hosted service, incl. an Akka.Hosting AkkaHostedService registered later), or
in StartAsync when no lifetime is registered.

- tests: EagerActorStartTests (generic host in both registration orders with a
  no-actor-before-start control; bare container) - all fail without the fix
- docs: README, CHANGELOG
- code-reviews: Secrets-010 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00
Joseph Doherty f60f641069 fix(secrets): ${secret:} expansion survives configuration reload (scadaproj#32)
ExpandConfigurationAsync wrote through config[key] = ..., which only sets the
providers' in-memory Data; a file provider's reload replaces it, so editing
appsettings.json on a running service reverted every expanded key to its
${secret:...} placeholder.

- ConfigurationManager: expanded values are pinned in an internal overlay
  source appended last; its provider's Load() is a no-op and Build always
  returns the same provider, so reloads (and source rebuilds) keep the pins
- any other IConfigurationRoot: in-place write as before, re-applied from the
  root's reload token
- all references resolve before anything is written (a fail-closed miss no
  longer leaves the config half expanded)
- tests: SecretExpansionReloadTests (real JSON file + Reload(), options
  monitor, plain root); three fail without the fix
- docs: README reload note, CHANGELOG
- code-reviews: Secrets-003 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00
Joseph Doherty c3b07d6766 fix(secrets): SqlServer/Akka extensions take contentRootPath; last explicit root wins (scadaproj#31)
The SQL-Server extensions called the 3-arg AddZbSecrets, and the store-path
validator captured the content root of whichever call registered it first.
In hub mode no registration order applied the pre-host under-content-root
guard: extension-first dropped the app's 4-arg root, core-first lost the
replicator (#30). That is the 0.6.0 store-path hole, reopened.

- AddZbSecretsSqlServerStore / AddZbSecretsSqlServerReplication /
  AddZbSecretsAkkaReplication gain a trailing optional contentRootPath,
  forwarded to the 4-arg AddZbSecrets
- the root now lives in one SecretsContentRoot instance holder per service
  collection; the last explicit root wins, null never erases one; the
  IHostEnvironment fallback is unchanged
- tests: ContentRootTests (hub mode, three compositions, artifact asserted
  before the throw, positive control), last-explicit-wins theory in
  SecretsStorePathRulesTests, Akka forwarding test
- code-reviews: Secrets-002 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00
Joseph Doherty 015c556ced fix(secrets): SqlServer/Akka registration is order-independent; bump 0.6.6 (scadaproj#30)
AddZbSecretsSqlServerStore / AddZbSecretsSqlServerReplication (and the Akka
extension, same defect class) TryAdd-ed their ISecretStore, ISecretsStoreMigrator
and ISecretReplicator ahead of their internal AddZbSecrets call. An application
that had already called AddZbSecrets therefore silently kept the SQLite store
(shared-store mode) or NoOpSecretReplicator (hub/Akka modes).

They now call AddZbSecrets first and RemoveAll + AddSingleton their own
registrations; a later AddZbSecrets only TryAdds, so either order yields the
same graph with exactly one ISecretStore registration.

- tests: RegistrationOrderTests (SqlServer + Akka) build and resolve the real
  container in both orders; core-first fails without the fix
- Directory.Build.props 0.6.5 -> 0.6.6 (NOT packed or published)
- code-reviews: Secrets-001 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:05 -05:00
Joseph Doherty 1786bab0a0 fix(telemetry): redact after ConfigureLogger enrichers, keep colliding dictionary keys, require http(s) OTLP endpoint and validate the options overload; bump 0.2.1 (scadaproj#48)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:57:48 -05:00
Joseph Doherty 5bf2f2caf1 fix(theme): nav-state.js tracks wired sections in a WeakSet so enhanced navigation cannot stack toggle listeners; bump 0.5.2 (scadaproj#48)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:56:33 -05:00
Joseph Doherty ab3fa9ca83 fix(health): AkkaActiveNodeGate.ForOptions shares the probe's RolePreference scope; bump 0.3.1 (scadaproj#48)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:55:45 -05:00
Joseph Doherty 3ef43ca926 fix(overview): P3 review fixes 003-005 - no redirects on probes, per-target publish so a hung instance cannot stale the rest, probe docs (scadaproj#48)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:54:12 -05:00
Joseph Doherty 362ba8d75f fix(galaxyrepository): P3 review fixes 003-008 - cycle re-rooting, scaled stale threshold, notifier ordering, DI tests, Pkcs 9.0.21 pin, snapshot docs; bump 0.2.2 (scadaproj#47)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:52:00 -05:00
Joseph Doherty 90b691af84 docs(deployment): correct the QA leader-resolution note after scadaproj#43
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:48:49 -05:00