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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ ASP.NET Core Data Protection (IXmlRepository)
- `DeletableStorageWrapper.DeleteElements` offers the parsed keys (malformed rows skipped), then passes the chosen `GetAll()` instances, ordered by `DeletionOrder`, to a single `Delete()` call. A storage exception is logged (`ErrorDeletingKeysFromStorage`) and returns `false`. Note: in ASP.NET Core 10.0, `XmlKeyManager` exposes `DeleteKeys` but does not implement `IDeletableKeyManager`.
- `StorageWrapper` (internal) creates a new DI scope and resolves `TStorage` on every `GetAllElements` / `StoreElement` call, so scoped storage implementations work. It trims `FriendlyName`, normalizes empty or whitespace-only names to `null`, and wraps any `Insert` exception in `KeyInsertException`. `GetAllElements` logs and skips rows with malformed XML (`SkippingMalformedKey`) instead of failing the whole key ring.
- Never log or expose key XML (it may contain unencrypted key material); this includes `XmlException` messages, which can quote the input.
- **DB providers:** `PersistKeysWithDapperInSqlServer(...)` / `PersistKeysWithDapperInPostgreSQL(...)` configure `IOptions<DapperDataProtectionConfig>`, register the connection source as a singleton (`SqlConnectionFactory` / `NpgsqlDataSource`), register the repository as scoped `IDbDataProtectionStorage`, then call `PersistKeysToStorage<IDbDataProtectionStorage>()`.
- **DB providers:** `PersistKeysWithDapperInSqlServer(...)` / `PersistKeysWithDapperInPostgreSQL(...)` configure `IOptions<DapperDataProtectionConfig>`, register the connection source as a singleton (`SqlConnectionFactory`; `NpgsqlDataSource` as a keyed singleton under the internal `Constants.DataSourceServiceKey`, created by a factory so the container disposes it, and so it never collides with the application's own data source), register the repository as scoped `IDbDataProtectionStorage`, then call `PersistKeysToStorage<IDbDataProtectionStorage>()`.
- `IServiceProvider.UseDapperDataProtection()` (in the shared Dapper project) must be called at startup. It resolves the registrations and runs `InitializeDb()` when `DapperDataProtectionConfig.InitializeTable` is `true` (the default).

### Database Implementations
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ app.Services.UseDapperDataProtection();
app.Run();
```

The provider registers its `NpgsqlDataSource` as a keyed service and disposes it with the service
provider. An `NpgsqlDataSource` registered by the application (for example with
`AddNpgsqlDataSource`) is neither replaced nor used for the keys, whatever the registration order.

## Configuration

Both providers accept an optional `Action<DapperDataProtectionConfig>` to override the defaults:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ public static class Constants
/// default keys table name
/// </summary>
public const string TableName = "data_protection_keys";
/// <summary>
/// service key of the <see cref="Npgsql.NpgsqlDataSource"/> registered by the provider,
/// so that it does not collide with a data source registered by the application
/// </summary>
internal const string DataSourceServiceKey = "AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL";
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ public static IDataProtectionBuilder PersistKeysWithDapperInPostgreSQL(this IDat
c.TableName = config.TableName;
});

var npgsqlDataSource = new NpgsqlDataSourceBuilder(connectionString).Build();

builder.Services.AddSingleton(npgsqlDataSource);
// Keyed, so that a data source registered by the application is neither replaced nor used;
// created by a factory, so that the container owns and disposes it.
builder.Services.AddKeyedSingleton(Constants.DataSourceServiceKey,
(_, _) => new NpgsqlDataSourceBuilder(connectionString).Build());

builder.Services.AddScoped<IDbDataProtectionStorage, PostgreSQLDataProtectionRepository>();
builder.PersistKeysToStorage<IDbDataProtectionStorage>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Npgsql;

Expand All @@ -28,7 +29,7 @@ private string GetAllQuery() =>
/// </summary>
/// <param name="dataSource">the db connection</param>
/// <param name="options">repository configuration</param>
public PostgreSQLDataProtectionRepository(NpgsqlDataSource dataSource,
public PostgreSQLDataProtectionRepository([FromKeyedServices(Constants.DataSourceServiceKey)] NpgsqlDataSource dataSource,
IOptionsSnapshot<DapperDataProtectionConfig> options)
{
_dataSource = dataSource;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Linq;
using System;
using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL;
using Dapper;
using FluentAssertions;
Expand All @@ -19,14 +19,37 @@ public void PersistKeysWithDapperInPostgreSQL_PreservesIncludeErrorDetail(string
{
var services = new ServiceCollection();
services.AddDataProtection().PersistKeysWithDapperInPostgreSQL(connectionString);
using var provider = services.BuildServiceProvider();

var dataSource = services.Single(d => d.ServiceType == typeof(NpgsqlDataSource))
.ImplementationInstance.Should().BeAssignableTo<NpgsqlDataSource>().Subject;
var dataSource = provider.GetRequiredKeyedService<NpgsqlDataSource>(Constants.DataSourceServiceKey);

new NpgsqlConnectionStringBuilder(dataSource.ConnectionString).IncludeErrorDetail
.Should().Be(expected);
}

[Fact]
public void PersistKeysWithDapperInPostgreSQL_DoesNotRegisterUnkeyedDataSource()
{
var services = new ServiceCollection();

services.AddDataProtection().PersistKeysWithDapperInPostgreSQL("Host=localhost;Database=test");

services.Should().NotContain(d => d.ServiceType == typeof(NpgsqlDataSource) && !d.IsKeyedService);
}

[Fact]
public void PersistKeysWithDapperInPostgreSQL_ServiceProviderDisposed_DisposesDataSource()
{
var services = new ServiceCollection();
services.AddDataProtection().PersistKeysWithDapperInPostgreSQL("Host=localhost;Database=test");
var provider = services.BuildServiceProvider();
var dataSource = provider.GetRequiredKeyedService<NpgsqlDataSource>(Constants.DataSourceServiceKey);

provider.Dispose();

dataSource.Invoking(d => d.OpenConnection()).Should().Throw<ObjectDisposedException>();
}

[Fact]
public void PersistKeysWithDapperInPostgreSQL_DoesNotEnableMatchNamesWithUnderscores()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Xunit;

namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL;
Expand Down Expand Up @@ -73,16 +74,49 @@ public void DeleteKeys_KeySelected_DeletesItFromTable()
GetStoredFriendlyNames(tableName).Should().Equal($"key-{kept.KeyId:D}");
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void Protect_ApplicationRegistersOwnDataSource_DataSourcesStaySeparate(bool applicationRegistersFirst)
{
var tableName = NewTableName();
// The application data source targets a database that does not exist, so that any key
// operation routed through it fails.
var applicationConnectionString = new NpgsqlConnectionStringBuilder(_fixture.ConnectionString)
{
Database = "application_database_does_not_exist"
}.ConnectionString;
using var applicationDataSource = NpgsqlDataSource.Create(applicationConnectionString);
using var services = BuildServiceProvider(tableName, applicationDataSource, applicationRegistersFirst);
var protector = services.GetRequiredService<IDataProtectionProvider>().CreateProtector(Purpose);

var protectedPayload = protector.Protect("payload");

services.GetRequiredService<NpgsqlDataSource>().Should().BeSameAs(applicationDataSource);
protector.Unprotect(protectedPayload).Should().Be("payload");
GetStoredFriendlyNames(tableName).Should().ContainSingle();
}

// PostgreSQL folds unquoted identifiers to lower case; keep the name short enough for derived index names.
private static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}";

private ServiceProvider BuildServiceProvider(string tableName)
private ServiceProvider BuildServiceProvider(string tableName,
NpgsqlDataSource? applicationDataSource = null,
bool applicationRegistersFirst = false)
{
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
if (applicationDataSource is not null && applicationRegistersFirst)
{
services.AddSingleton(applicationDataSource);
}
services.AddDataProtection()
.SetApplicationName(ApplicationName)
.PersistKeysWithDapperInPostgreSQL(_fixture.ConnectionString, config => config.TableName = tableName);
if (applicationDataSource is not null && !applicationRegistersFirst)
{
services.AddSingleton(applicationDataSource);
}

var provider = services.BuildServiceProvider();
provider.UseDapperDataProtection();
Expand Down
Loading