Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Squid.Core.Persistence.Db;
using Squid.Core.Services.Security;

namespace Squid.Core.Services.Deployments.ExternalFeeds;

Expand All @@ -17,17 +19,39 @@ public interface IExternalFeedDataProvider : IScopedDependency
Task<Persistence.Entities.Deployments.ExternalFeed> GetFeedByIdAsync(int feedId, CancellationToken cancellationToken = default);
}

public class ExternalFeedDataProvider(IUnitOfWork unitOfWork, IRepository repository) : IExternalFeedDataProvider
/// <summary>
/// Owns at-rest protection of <see cref="Persistence.Entities.Deployments.ExternalFeed.Password"/>
/// — 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
/// <c>DeploymentAccountDataProvider</c> / <c>VariableDataProvider</c>.
///
/// <para>Read-both / non-breaking: <see cref="IVariableEncryptionService.EncryptAsync"/> always
/// emits the <c>SQUID_ENCRYPTED_V2:</c> envelope; <c>DecryptAsync</c> 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 (<c>QueryNoTracking</c>) so decrypting the
/// in-memory entity can never be flushed back to the DB as plaintext by a later shared-scope
/// SaveChanges.</para>
/// </summary>
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);
}

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);
Expand All @@ -42,7 +66,7 @@ public async Task DeleteExternalFeedsAsync(List<Persistence.Entities.Deployments

public async Task<(int count, List<Persistence.Entities.Deployments.ExternalFeed>)> GetExternalFeedPagingAsync(int? spaceId = null, int? pageIndex = null, int? pageSize = null, CancellationToken cancellationToken = default)
{
var query = repository.Query<Persistence.Entities.Deployments.ExternalFeed>();
var query = repository.QueryNoTracking<Persistence.Entities.Deployments.ExternalFeed>();

if (spaceId.HasValue)
query = query.Where(f => f.SpaceId == spaceId.Value);
Expand All @@ -52,18 +76,54 @@ public async Task DeleteExternalFeedsAsync(List<Persistence.Entities.Deployments
if (pageIndex.HasValue && pageSize.HasValue)
query = query.Skip((pageIndex.Value - 1) * pageSize.Value).Take(pageSize.Value);

return (count, await query.ToListAsync(cancellationToken).ConfigureAwait(false));
var feeds = await query.ToListAsync(cancellationToken).ConfigureAwait(false);

return (count, await DecryptPasswordsAsync(feeds).ConfigureAwait(false));
}

public async Task<List<Persistence.Entities.Deployments.ExternalFeed>> GetExternalFeedsByIdsAsync(List<int> ids, CancellationToken cancellationToken)
{
return await repository.Query<Persistence.Entities.Deployments.ExternalFeed>()
var feeds = await repository.QueryNoTracking<Persistence.Entities.Deployments.ExternalFeed>()
.Where(f => ids.Contains(f.Id))
.ToListAsync(cancellationToken).ConfigureAwait(false);

return await DecryptPasswordsAsync(feeds).ConfigureAwait(false);
}

public async Task<Persistence.Entities.Deployments.ExternalFeed> GetFeedByIdAsync(int feedId, CancellationToken cancellationToken = default)
{
return await repository.GetByIdAsync<Persistence.Entities.Deployments.ExternalFeed>(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<Persistence.Entities.Deployments.ExternalFeed>(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<Persistence.Entities.Deployments.ExternalFeed> 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<List<Persistence.Entities.Deployments.ExternalFeed>> DecryptPasswordsAsync(List<Persistence.Entities.Deployments.ExternalFeed> feeds)
{
foreach (var feed in feeds)
await DecryptPasswordAsync(feed).ConfigureAwait(false);

return feeds;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// End-to-end (real Postgres + real EF + real <see cref="Squid.Core.Services.Security.IVariableEncryptionService"/>
/// with the test MasterKey) coverage of at-rest encryption for
/// <see cref="ExternalFeed.Password"/> through the real <see cref="IExternalFeedDataProvider"/>
/// seam: a written feed's RAW column carries the <c>SQUID_ENCRYPTED_V2:</c> 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).
/// </summary>
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<IExternalFeedDataProvider, int>(async provider =>
{
var feed = NewFeed(Secret);
await provider.AddExternalFeedAsync(feed).ConfigureAwait(false);
return feed.Id;
}).ConfigureAwait(false);

await Run<IRepository>(async repository =>
{
var raw = await repository.Query<ExternalFeed>(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<IExternalFeedDataProvider>(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<IRepository, IUnitOfWork>(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<IExternalFeedDataProvider>(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<IExternalFeedDataProvider, int>(async provider =>
{
var feed = NewFeed(Secret);
await provider.AddExternalFeedAsync(feed).ConfigureAwait(false);
return feed.Id;
}).ConfigureAwait(false);

await Run<IExternalFeedDataProvider, IUnitOfWork>(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<IRepository>(async repository =>
{
var raw = await repository.Query<ExternalFeed>(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<IExternalFeedDataProvider, int>(async provider =>
{
var feed = NewFeed(Secret);
await provider.AddExternalFeedAsync(feed).ConfigureAwait(false);
return feed.Id;
}).ConfigureAwait(false);

await Run<IExternalFeedDataProvider>(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<IRepository>(async repository =>
{
var raw = await repository.Query<ExternalFeed>(f => f.Id == id).FirstOrDefaultAsync().ConfigureAwait(false);
raw.Password.ShouldStartWith("SQUID_ENCRYPTED_V2:");
raw.Password.ShouldNotContain(rotated);
}).ConfigureAwait(false);

await Run<IExternalFeedDataProvider>(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
};
}
Loading