Extract AtRestSecretProtector as the single at-rest secret contract
Summary
- The account / feed / certificate DataProviders each carried a near-identical value transform: encrypt-if-needed (guarded by
IsValidEncryptedValueso a re-save never double-wraps) on write, and read-both decrypt (legacy plaintext passes through verbatim) on read — four copies of the same contract, and the upcoming SSH proxy-password slice would have been a fifth. - Extract it into
IAtRestSecretProtector(Protect/UnprotectAsyncover a KDF scope), wrappingIVariableEncryptionService. The three providers now depend on this narrow SSOT instead of re-implementing it. - The entity-level anti-flush concern (
QueryNoTracking, orDetachbefore the in-place decrypt) stays in each provider — it is entity-specific and orthogonal to the value transform.
Foundation for the remaining P2 at-rest finishers (SSH proxy password, then Tentacle private key).
Non-breaking
Pure refactor, behaviour-identical: the provider ctors swap IVariableEncryptionService for IAtRestSecretProtector (both auto-registered via IScopedDependency, DI-resolved everywhere — no call site constructs them by hand). No interface/enum/signature/wire/DB change.
Test plan
-
Unit — AtRestSecretProtectorTests: idempotentProtect(null / empty / already-encrypted passthrough, no double-wrap) + read-bothUnprotectAsync(delegates to the encryption service, legacy plaintext passthrough) + KDF scope forwarded. -
Integration (real Postgres + real DI) — the existing account / feed / certificate at-rest round-trip tests pass unchanged through the now-DI-resolved real protector (12/12), proving behaviour is preserved. -
Full unit suite green (6043).