diff --git a/src/Squid.Core/Services/Deployments/ExternalFeeds/ExternalFeedDataProvider.cs b/src/Squid.Core/Services/Deployments/ExternalFeeds/ExternalFeedDataProvider.cs index 5cbacc19..ea623138 100644 --- a/src/Squid.Core/Services/Deployments/ExternalFeeds/ExternalFeedDataProvider.cs +++ b/src/Squid.Core/Services/Deployments/ExternalFeeds/ExternalFeedDataProvider.cs @@ -1,4 +1,6 @@ +using Microsoft.EntityFrameworkCore; using Squid.Core.Persistence.Db; +using Squid.Core.Services.Security; namespace Squid.Core.Services.Deployments.ExternalFeeds; @@ -17,10 +19,30 @@ public interface IExternalFeedDataProvider : IScopedDependency Task GetFeedByIdAsync(int feedId, CancellationToken cancellationToken = default); } -public class ExternalFeedDataProvider(IUnitOfWork unitOfWork, IRepository repository) : IExternalFeedDataProvider +/// +/// Owns at-rest protection of +/// — the package-feed credential, previously persisted in cleartext. The single seam every feed +/// read and write funnels through (deploy-pipeline package fetch, Helm/K8s registry auth, the +/// feed service), so encrypting here covers every consumer transparently — same pattern as +/// DeploymentAccountDataProvider / VariableDataProvider. +/// +/// Read-both / non-breaking: always +/// emits the SQUID_ENCRYPTED_V2: envelope; DecryptAsync returns any value lacking +/// that prefix verbatim, so pre-existing plaintext rows still load and upgrade lazily on next save. +/// Reads use the repository's AsNoTracking query (QueryNoTracking) so decrypting the +/// in-memory entity can never be flushed back to the DB as plaintext by a later shared-scope +/// SaveChanges. +/// +public class ExternalFeedDataProvider(IUnitOfWork unitOfWork, IRepository repository, IVariableEncryptionService encryption) : IExternalFeedDataProvider { + // The V2 envelope derives its key from a random per-payload salt, so the legacy KDF-scope + // argument (a V1-only deterministic-salt input) is irrelevant for feeds, which only write V2. + private const int PasswordKdfScope = 0; + public async Task AddExternalFeedAsync(Persistence.Entities.Deployments.ExternalFeed externalFeed, bool forceSave = true, CancellationToken cancellationToken = default) { + EncryptPassword(externalFeed); + await repository.InsertAsync(externalFeed, cancellationToken).ConfigureAwait(false); if (forceSave) await unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); @@ -28,6 +50,8 @@ public async Task AddExternalFeedAsync(Persistence.Entities.Deployments.External public async Task UpdateExternalFeedAsync(Persistence.Entities.Deployments.ExternalFeed externalFeed, bool forceSave = true, CancellationToken cancellationToken = default) { + EncryptPassword(externalFeed); + await repository.UpdateAsync(externalFeed, cancellationToken).ConfigureAwait(false); if (forceSave) await unitOfWork.SaveChangesAsync(cancellationToken).ConfigureAwait(false); @@ -42,7 +66,7 @@ public async Task DeleteExternalFeedsAsync(List)> GetExternalFeedPagingAsync(int? spaceId = null, int? pageIndex = null, int? pageSize = null, CancellationToken cancellationToken = default) { - var query = repository.Query(); + var query = repository.QueryNoTracking(); if (spaceId.HasValue) query = query.Where(f => f.SpaceId == spaceId.Value); @@ -52,18 +76,54 @@ public async Task DeleteExternalFeedsAsync(List> GetExternalFeedsByIdsAsync(List ids, CancellationToken cancellationToken) { - return await repository.Query() + var feeds = await repository.QueryNoTracking() .Where(f => ids.Contains(f.Id)) .ToListAsync(cancellationToken).ConfigureAwait(false); + + return await DecryptPasswordsAsync(feeds).ConfigureAwait(false); } public async Task GetFeedByIdAsync(int feedId, CancellationToken cancellationToken = default) { - return await repository.GetByIdAsync(feedId, cancellationToken).ConfigureAwait(false); + // QueryNoTracking so the decrypt below mutates a DETACHED entity — a tracked entity would + // be detected as Modified and a later in-scope SaveChanges would flush plaintext back. + var feed = await repository.QueryNoTracking(f => f.Id == feedId) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + return await DecryptPasswordAsync(feed).ConfigureAwait(false); + } + + // Encrypt only if not already an envelope — idempotent, so a re-save never double-wraps. + private void EncryptPassword(Persistence.Entities.Deployments.ExternalFeed feed) + { + if (feed == null || string.IsNullOrEmpty(feed.Password)) return; + if (encryption.IsValidEncryptedValue(feed.Password)) return; + + feed.Password = encryption.EncryptAsync(feed.Password, PasswordKdfScope); + } + + private async Task DecryptPasswordAsync(Persistence.Entities.Deployments.ExternalFeed feed) + { + if (feed == null || string.IsNullOrEmpty(feed.Password)) return feed; + + // Read-both: a plaintext (unprefixed) value is returned verbatim by DecryptAsync. + feed.Password = await encryption.DecryptAsync(feed.Password, PasswordKdfScope).ConfigureAwait(false); + + return feed; + } + + private async Task> DecryptPasswordsAsync(List feeds) + { + foreach (var feed in feeds) + await DecryptPasswordAsync(feed).ConfigureAwait(false); + + return feeds; } } diff --git a/tests/Squid.IntegrationTests/Services/ExternalFeeds/IntegrationExternalFeedPasswordAtRest.cs b/tests/Squid.IntegrationTests/Services/ExternalFeeds/IntegrationExternalFeedPasswordAtRest.cs new file mode 100644 index 00000000..0afab919 --- /dev/null +++ b/tests/Squid.IntegrationTests/Services/ExternalFeeds/IntegrationExternalFeedPasswordAtRest.cs @@ -0,0 +1,141 @@ +using Microsoft.EntityFrameworkCore; +using Squid.Core.Persistence.Db; +using Squid.Core.Persistence.Entities.Deployments; +using Squid.Core.Services.Deployments.ExternalFeeds; + +namespace Squid.IntegrationTests.Services.ExternalFeeds; + +/// +/// End-to-end (real Postgres + real EF + real +/// with the test MasterKey) coverage of at-rest encryption for +/// through the real +/// seam: a written feed's RAW column carries the SQUID_ENCRYPTED_V2: envelope and never the +/// cleartext password; reads decrypt transparently; a pre-existing PLAINTEXT row still reads back +/// (read-both / zero-migration); and — the regression that bit the account slice — a read+decrypt +/// followed by an unrelated SaveChanges in the SAME scope must NOT flush plaintext back (reads are +/// AsNoTracking). +/// +public class IntegrationExternalFeedPasswordAtRest : TestBase +{ + private const string Secret = "feed-secret-pat-4f1c-VALUE"; + + public IntegrationExternalFeedPasswordAtRest() + : base("ExternalFeedPasswordAtRest", "squid_it_feed_pw_atrest") + { + } + + [Fact] + public async Task Create_StoresPasswordEncrypted_AndReadsBackDecrypted() + { + var id = await Run(async provider => + { + var feed = NewFeed(Secret); + await provider.AddExternalFeedAsync(feed).ConfigureAwait(false); + return feed.Id; + }).ConfigureAwait(false); + + await Run(async repository => + { + var raw = await repository.Query(f => f.Id == id).FirstOrDefaultAsync().ConfigureAwait(false); + + raw.ShouldNotBeNull(); + raw.Password.ShouldStartWith("SQUID_ENCRYPTED_V2:", customMessage: "the persisted feed password must be a V2 envelope, never cleartext"); + raw.Password.ShouldNotContain(Secret, customMessage: "the secret must not survive verbatim in the DB column"); + }).ConfigureAwait(false); + + await Run(async provider => + { + var loaded = await provider.GetFeedByIdAsync(id).ConfigureAwait(false); + loaded.Password.ShouldBe(Secret, customMessage: "the provider must decrypt on read"); + }).ConfigureAwait(false); + } + + [Fact] + public async Task LegacyPlaintextRow_ReadsBack_NonBreaking() + { + var id = 0; + await Run(async (repository, unitOfWork) => + { + var feed = NewFeed(Secret); // inserted raw (bypassing the encrypting provider) = legacy plaintext + await repository.InsertAsync(feed).ConfigureAwait(false); + await unitOfWork.SaveChangesAsync().ConfigureAwait(false); + id = feed.Id; + }).ConfigureAwait(false); + + await Run(async provider => + { + var loaded = await provider.GetFeedByIdAsync(id).ConfigureAwait(false); + loaded.Password.ShouldBe(Secret, customMessage: "read-both: a pre-existing plaintext row must read back verbatim"); + }).ConfigureAwait(false); + } + + [Fact] + public async Task ReadThenUnrelatedSaveInSameScope_DoesNotFlushPlaintextBack() + { + var id = await Run(async provider => + { + var feed = NewFeed(Secret); + await provider.AddExternalFeedAsync(feed).ConfigureAwait(false); + return feed.Id; + }).ConfigureAwait(false); + + await Run(async (provider, unitOfWork) => + { + var loaded = await provider.GetFeedByIdAsync(id).ConfigureAwait(false); + loaded.Password.ShouldBe(Secret); + + await unitOfWork.SaveChangesAsync().ConfigureAwait(false); // would flush plaintext if the read tracked + }).ConfigureAwait(false); + + await Run(async repository => + { + var raw = await repository.Query(f => f.Id == id).FirstOrDefaultAsync().ConfigureAwait(false); + raw.Password.ShouldStartWith("SQUID_ENCRYPTED_V2:", + customMessage: "a read+decrypt then unrelated same-scope SaveChanges must NOT un-encrypt the column — reads must be AsNoTracking"); + }).ConfigureAwait(false); + } + + [Fact] + public async Task Update_ReEncryptsPassword() + { + const string rotated = "feed-secret-rotated-8a2d-VALUE"; + + var id = await Run(async provider => + { + var feed = NewFeed(Secret); + await provider.AddExternalFeedAsync(feed).ConfigureAwait(false); + return feed.Id; + }).ConfigureAwait(false); + + await Run(async provider => + { + var loaded = await provider.GetFeedByIdAsync(id).ConfigureAwait(false); // decrypted + loaded.Password = rotated; // plaintext rotation + await provider.UpdateExternalFeedAsync(loaded).ConfigureAwait(false); // re-encrypts + }).ConfigureAwait(false); + + await Run(async repository => + { + var raw = await repository.Query(f => f.Id == id).FirstOrDefaultAsync().ConfigureAwait(false); + raw.Password.ShouldStartWith("SQUID_ENCRYPTED_V2:"); + raw.Password.ShouldNotContain(rotated); + }).ConfigureAwait(false); + + await Run(async provider => + { + var loaded = await provider.GetFeedByIdAsync(id).ConfigureAwait(false); + loaded.Password.ShouldBe(rotated, customMessage: "the rotated secret must decrypt back after update"); + }).ConfigureAwait(false); + } + + private static ExternalFeed NewFeed(string password) => new() + { + SpaceId = 1, + Name = $"at-rest-feed-{Guid.NewGuid():N}"[..24], + Slug = $"feed-{Guid.NewGuid():N}", + FeedType = "Docker", + FeedUri = "https://registry.example.com", + Username = "svc", + Password = password + }; +}