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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ 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` 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).
- **DB providers:** `PersistKeysWithDapperInSqlServer(...)` / `PersistKeysWithDapperInPostgreSQL(...)` configure `IOptions<DapperDataProtectionConfig>` with an internal per-provider `DapperDataProtectionConfigValidator` and `ValidateOnStart()`, 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). Reading the options there also triggers validation when no host runs `ValidateOnStart`.

### Database Implementations

Expand All @@ -67,7 +67,9 @@ Both repositories use raw SQL via Dapper. They differ in naming conventions:

- The PostgreSQL `SELECT` aliases each snake_case column to its `DataProtectionKeyEntity` property name (`AS "{nameof(...)}"`). Never set the process-wide `Dapper.DefaultTypeMap.MatchNamesWithUnderscores`: it changes column mapping for every Dapper query in the host application.
- `Delete()` requires `DataProtectionKeyEntity` instances (as returned by `GetAll()`) and deletes by `Id`, row by row in order, in one transaction.
- Schema and table names are interpolated directly into SQL strings, not parameterized or validated.
- Schema and table names are validated at startup (SQL Server: `^[A-Za-z_][A-Za-z0-9_]*`, schema <= 128, table <= 112 to fit `IX_{table}_FriendlyName`; PostgreSQL: lowercase only, <= 63) and always quoted (`[...]` / `"..."`, with the closing quote escaped). PostgreSQL names are lowercase because earlier versions did not quote them: a mixed-case name would silently target a new, empty table.
- SQL Server passes names as parameters wherever T-SQL accepts them (existence checks, `QUOTENAME`, the duplicate-index error message).
- `InitializeDb()` creates a missing schema. It checks existence first (`SCHEMA_ID` / `pg_namespace`), because PostgreSQL's `CREATE SCHEMA IF NOT EXISTS` requires the database CREATE privilege even when the schema exists.
- Defaults are in each provider's `Constants.cs`. Keep the table DDL in sync with the "Database schema" section of `README.md`.

### Testing
Expand Down
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The base package lets you plug any backend into the Data Protection key ring by
- [SQL Server](#sql-server)
- [PostgreSQL](#postgresql)
- [Configuration](#configuration)
- [Schema and table names](#schema-and-table-names)
- [Database schema](#database-schema)
- [Custom storage backend](#custom-storage-backend)
- [Deleting keys](#deleting-keys)
Expand Down Expand Up @@ -137,21 +138,42 @@ builder.Services.AddDataProtection()

| Option | Type | Default (SQL Server) | Default (PostgreSQL) | Description |
|--------|------|----------------------|----------------------|-------------|
| `SchemaName` | `string` | `dbo` | `public` | Schema that contains the keys table. |
| `TableName` | `string` | `DataProtectionKeys` | `data_protection_keys` | Name of the keys table. |
| `InitializeTable` | `bool` | `true` | `true` | When `true`, `UseDapperDataProtection()` creates the table and its unique index, each one only if it is missing. |
| `SchemaName` | `string` | `dbo` | `public` | Schema that contains the keys table. See [Schema and table names](#schema-and-table-names). |
| `TableName` | `string` | `DataProtectionKeys` | `data_protection_keys` | Name of the keys table. See [Schema and table names](#schema-and-table-names). |
| `InitializeTable` | `bool` | `true` | `true` | When `true`, `UseDapperDataProtection()` creates the schema, the table and its unique index, each one only if it is missing. |

`UseDapperDataProtection()` is an extension on `IServiceProvider`. Call it once at startup
(`app.Services.UseDapperDataProtection()`): it validates the service registrations and, when
`InitializeTable` is `true`, creates the table and the friendly-name unique index if they are
missing. The index is also created on an existing table that lacks it; if that table already
`InitializeTable` is `true`, creates the schema, the table and the friendly-name unique index if
they are missing. The schema is created only when it does not exist, so the database user needs the
permission to create schemas only in that case. The index is also created on an existing table that lacks it; if that table already
contains duplicate non-null friendly names, initialization fails and the duplicates must be
removed before retrying. Initialization is safe when several instances start at the same time:
it runs in a transaction that holds a database-wide lock (`sp_getapplock` on SQL Server,
`pg_advisory_xact_lock` on PostgreSQL), so concurrent calls wait for each other. If you set
`InitializeTable = false` you are responsible for creating the schema (see below) before the
app stores keys.

### Schema and table names

`SchemaName` and `TableName` are validated when the host starts, and at the latest when
`UseDapperDataProtection()` runs. An invalid name throws an `OptionsValidationException` that names
the option and the rule:

| | SQL Server | PostgreSQL |
|---|---|---|
| Allowed characters | letters, digits and `_`; must not start with a digit | lowercase letters, digits and `_`; must not start with a digit |
| Maximum length | 128 for the schema, 112 for the table (room for the `IX_{TableName}_FriendlyName` index name) | 63 |

The names are always quoted in the generated SQL, so reserved words such as `order` or `user` are
allowed.

PostgreSQL folds unquoted names to lowercase, and earlier versions of this library did not quote
them. Quoted names are case-sensitive, so a mixed-case name such as `DpKeys` would now refer to a
new, empty table instead of the existing `dpkeys` table, and the existing keys would be ignored.
Mixed-case names are therefore rejected at startup: if you configured one, replace it with its
lowercase form, which is the name of the table created by the earlier version.

## Database schema

When `InitializeTable` is enabled, the provider creates a table equivalent to the following.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;

namespace AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL;

/// <summary>
/// Validates the schema and table names of <see cref="DapperDataProtectionConfig"/> for PostgreSQL.
/// </summary>
/// <remarks>
/// Names must be lowercase. Identifiers are quoted, which makes them case-sensitive, while earlier versions
/// used unquoted identifiers that PostgreSQL folds to lowercase. Accepting a mixed-case name would silently
/// target a different, empty table instead of the one created by an earlier version.
/// </remarks>
internal sealed partial class DapperDataProtectionConfigValidator : IValidateOptions<DapperDataProtectionConfig>
{
/// <summary>
/// Maximum length of a PostgreSQL identifier (NAMEDATALEN - 1).
/// </summary>
internal const int MaxNameLength = 63;

/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options)
{
var failures = new List<string>();
Validate(nameof(DapperDataProtectionConfig.SchemaName), options.SchemaName, failures);
Validate(nameof(DapperDataProtectionConfig.TableName), options.TableName, failures);
return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
}

private static void Validate(string optionName, string? value, List<string> failures)
{
if (value is null || value.Length > MaxNameLength || !IdentifierRegex().IsMatch(value))
{
failures.Add(
$"{nameof(DapperDataProtectionConfig)}.{optionName} '{value}' is not a valid PostgreSQL name: " +
"it must start with a lowercase letter or an underscore, contain only lowercase letters, digits " +
$"and underscores, and be at most {MaxNameLength} characters long.");
}
}

[GeneratedRegex(@"^[a-z_][a-z0-9_]*\z", RegexOptions.CultureInvariant)]
private static partial Regex IdentifierRegex();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Npgsql;

namespace AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL;
Expand Down Expand Up @@ -30,12 +32,16 @@ public static IDataProtectionBuilder PersistKeysWithDapperInPostgreSQL(this IDat
TableName = Constants.TableName
};
configAction?.Invoke(config);
builder.Services.Configure<DapperDataProtectionConfig>(c =>
{
c.InitializeTable = config.InitializeTable;
c.SchemaName = config.SchemaName;
c.TableName = config.TableName;
});
builder.Services.AddOptions<DapperDataProtectionConfig>()
.Configure(c =>
{
c.InitializeTable = config.InitializeTable;
c.SchemaName = config.SchemaName;
c.TableName = config.TableName;
})
.ValidateOnStart();
builder.Services.TryAddEnumerable(ServiceDescriptor
.Singleton<IValidateOptions<DapperDataProtectionConfig>, DapperDataProtectionConfigValidator>());

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ public class PostgreSQLDataProtectionRepository : IDbDataProtectionStorage
private const string InitializeDbLockName = "AspNetCore.DataProtection.CustomStorage.InitializeDb";
private readonly NpgsqlDataSource _dataSource;
private readonly DapperDataProtectionConfig _config;
private readonly string _tableName;
private string GetAllQuery() =>
$"""
SELECT
id AS "{nameof(DataProtectionKeyEntity.Id)}",
insert_date AS "{nameof(DataProtectionKeyEntity.InsertDate)}",
friendly_name AS "{nameof(DataProtectionKeyEntity.FriendlyName)}",
xml AS "{nameof(DataProtectionKeyEntity.Xml)}"
FROM {_config.SchemaName}.{_config.TableName};
FROM {_tableName};
""";

/// <summary>
Expand All @@ -36,6 +37,7 @@ public PostgreSQLDataProtectionRepository([FromKeyedServices(Constants.DataSourc
{
_dataSource = dataSource;
_config = options.Value;
_tableName = $"{QuoteIdentifier(_config.SchemaName)}.{QuoteIdentifier(_config.TableName)}";
}

/// <inheritdoc />
Expand All @@ -59,7 +61,7 @@ public void Insert(DataProtectionKey key)
using var connection = _dataSource.CreateConnection();
connection.Execute(
$"""
INSERT INTO {_config.SchemaName}.{_config.TableName}(
INSERT INTO {_tableName}(
friendly_name,
xml)
VALUES (
Expand Down Expand Up @@ -88,7 +90,7 @@ public void Delete(IReadOnlyCollection<DataProtectionKey> keys)
// Dapper executes the statement once per id, in order.
connection.Execute(
$"""
DELETE FROM {_config.SchemaName}.{_config.TableName}
DELETE FROM {_tableName}
WHERE id = @{nameof(DataProtectionKeyEntity.Id)}
""",
ids.Select(id => new { Id = id }),
Expand All @@ -108,6 +110,7 @@ private static List<int> GetIds(IReadOnlyCollection<DataProtectionKey> keys)

/// <inheritdoc />
/// <remarks>
/// Creates the schema, the table and the friendly-name unique index, each one only if it is missing.
/// Safe to call concurrently from several instances: the initialization is serialized
/// with a transaction-level advisory lock.
/// </remarks>
Expand All @@ -117,25 +120,39 @@ public void InitializeDb()
using var transaction = connection.BeginTransaction();
// Without the lock, concurrent IF NOT EXISTS statements can all create the same relation
// and fail with a unique violation in the system catalogs.
connection.Execute($"""
SELECT pg_advisory_xact_lock(hashtext(@LockName));
connection.Execute("SELECT pg_advisory_xact_lock(hashtext(@LockName));",
new { LockName = InitializeDbLockName },
transaction);
// CREATE SCHEMA IF NOT EXISTS requires the CREATE privilege on the database even when the schema
// exists, so it is executed only when the schema is missing.
var schemaExists = connection.ExecuteScalar<bool>(
"SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = @SchemaName);",
new { _config.SchemaName },
transaction);
if (!schemaExists)
{
connection.Execute($"CREATE SCHEMA IF NOT EXISTS {QuoteIdentifier(_config.SchemaName)};",
transaction: transaction);
}

CREATE TABLE IF NOT EXISTS {_config.SchemaName}.{_config.TableName}
connection.Execute($"""
CREATE TABLE IF NOT EXISTS {_tableName}
(
id INTEGER generated always as identity,
insert_date timestamp with time zone NOT NULL default NOW(),
friendly_name character varying(256) COLLATE pg_catalog."default" NULL,
xml text COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT pk_{_config.SchemaName}_{_config.TableName} PRIMARY KEY (id)
CONSTRAINT {QuoteIdentifier($"pk_{_config.SchemaName}_{_config.TableName}")} PRIMARY KEY (id)
);

CREATE UNIQUE INDEX IF NOT EXISTS ix_{_config.SchemaName}_{_config.TableName}_friendly_name
ON {_config.SchemaName}.{_config.TableName} USING btree
CREATE UNIQUE INDEX IF NOT EXISTS {QuoteIdentifier($"ix_{_config.SchemaName}_{_config.TableName}_friendly_name")}
ON {_tableName} USING btree
(friendly_name COLLATE pg_catalog."default" ASC NULLS LAST)
TABLESPACE pg_default;
""",
new { LockName = InitializeDbLockName },
transaction);
transaction: transaction);
transaction.Commit();
}

private static string QuoteIdentifier(string name) => $"\"{name.Replace("\"", "\"\"")}\"";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;

namespace AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer;

/// <summary>
/// Validates the schema and table names of <see cref="DapperDataProtectionConfig"/> for SQL Server.
/// </summary>
internal sealed partial class DapperDataProtectionConfigValidator : IValidateOptions<DapperDataProtectionConfig>
{
/// <summary>
/// Maximum length of a SQL Server identifier (sysname).
/// </summary>
internal const int MaxSchemaNameLength = 128;

/// <summary>
/// Maximum table name length, which leaves room for the derived index name <c>IX_{TableName}_FriendlyName</c>.
/// </summary>
internal const int MaxTableNameLength = MaxSchemaNameLength - 16;

/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options)
{
var failures = new List<string>();
Validate(nameof(DapperDataProtectionConfig.SchemaName), options.SchemaName, MaxSchemaNameLength, failures);
Validate(nameof(DapperDataProtectionConfig.TableName), options.TableName, MaxTableNameLength, failures);
return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures);
}

private static void Validate(string optionName, string? value, int maxLength, List<string> failures)
{
if (value is null || value.Length > maxLength || !IdentifierRegex().IsMatch(value))
{
failures.Add(
$"{nameof(DapperDataProtectionConfig)}.{optionName} '{value}' is not a valid SQL Server name: " +
"it must start with a letter or an underscore, contain only letters, digits and underscores, " +
$"and be at most {maxLength} characters long.");
}
}

[GeneratedRegex(@"^[A-Za-z_][A-Za-z0-9_]*\z", RegexOptions.CultureInvariant)]
private static partial Regex IdentifierRegex();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;

namespace AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer;

Expand Down Expand Up @@ -28,12 +30,16 @@ public static IDataProtectionBuilder PersistKeysWithDapperInSqlServer(this IData
TableName = Constants.TableName
};
configAction?.Invoke(config);
builder.Services.Configure<DapperDataProtectionConfig>(c =>
{
c.InitializeTable = config.InitializeTable;
c.SchemaName = config.SchemaName;
c.TableName = config.TableName;
});
builder.Services.AddOptions<DapperDataProtectionConfig>()
.Configure(c =>
{
c.InitializeTable = config.InitializeTable;
c.SchemaName = config.SchemaName;
c.TableName = config.TableName;
})
.ValidateOnStart();
builder.Services.TryAddEnumerable(ServiceDescriptor
.Singleton<IValidateOptions<DapperDataProtectionConfig>, DapperDataProtectionConfigValidator>());

builder.Services.AddSingleton(_=>new SqlConnectionFactory(connectionString));
builder.Services.AddScoped<IDbDataProtectionStorage, SQLServerDataProtectionRepository>();
Expand Down
Loading
Loading