From 0e8d4a7db1f0101f54d1fb873ada64e1a3074321 Mon Sep 17 00:00:00 2001 From: giammin Date: Thu, 22 Oct 2026 23:22:39 +0200 Subject: [PATCH] fix(dapper)!: validate and quote schema and table names Validate DapperDataProtectionConfig.SchemaName and TableName at startup (options validation with ValidateOnStart) with a per-provider validator: SQL Server accepts letters, digits and underscores (schema <= 128, table <= 112 characters); PostgreSQL accepts the same in lowercase only (<= 63). Quote every identifier, pass names as parameters in the SQL Server existence checks, and create the schema in InitializeDb when it is missing. Closes #22 BREAKING CHANGE: names that do not match the new rules now fail at startup with an OptionsValidationException. SQL Server names containing characters other than letters, digits and underscores must be renamed. PostgreSQL names must be lowercase: replace a mixed-case name with its lowercase form, which is the name of the table created by earlier versions. --- AGENTS.md | 8 +- README.md | 32 ++++++- .../DapperDataProtectionConfigValidator.cs | 44 +++++++++ .../DapperDataProtectionExtensions.cs | 18 ++-- .../PostgreSQLDataProtectionRepository.cs | 39 +++++--- .../DapperDataProtectionConfigValidator.cs | 44 +++++++++ .../DapperDataProtectionExtensions.cs | 18 ++-- .../SQLServerDataProtectionRepository.cs | 41 ++++++--- .../DapperDataProtectionConfig.cs | 9 +- ...apperDataProtectionConfigValidatorTests.cs | 82 +++++++++++++++++ ...eSqlDapperDataProtectionExtensionsTests.cs | 38 ++++++++ ...PostgreSqlDataProtectionRepositoryTests.cs | 45 +++++++++ .../PostgreSQL/PostgreSqlEndToEndTests.cs | 27 +++++- ...apperDataProtectionConfigValidatorTests.cs | 91 +++++++++++++++++++ ...rverDapperDataProtectionExtensionsTests.cs | 51 +++++++++++ .../SqlServerDataProtectionRepositoryTests.cs | 14 +++ .../SQLServer/SqlServerEndToEndTests.cs | 26 +++++- 17 files changed, 573 insertions(+), 54 deletions(-) create mode 100644 src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionConfigValidator.cs create mode 100644 src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs create mode 100644 tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionConfigValidatorTests.cs create mode 100644 tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionConfigValidatorTests.cs create mode 100644 tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionExtensionsTests.cs diff --git a/AGENTS.md b/AGENTS.md index 61d2d08..65e0638 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, 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()`. -- `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` 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()`. +- `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 @@ -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 diff --git a/README.md b/README.md index 8fcc0a8..8c2ebe1 100644 --- a/README.md +++ b/README.md @@ -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) @@ -137,14 +138,15 @@ 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, @@ -152,6 +154,26 @@ it runs in a transaction that holds a database-wide lock (`sp_getapplock` on SQL `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. diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionConfigValidator.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionConfigValidator.cs new file mode 100644 index 0000000..fb03099 --- /dev/null +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionConfigValidator.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; + +namespace AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; + +/// +/// Validates the schema and table names of for PostgreSQL. +/// +/// +/// 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. +/// +internal sealed partial class DapperDataProtectionConfigValidator : IValidateOptions +{ + /// + /// Maximum length of a PostgreSQL identifier (NAMEDATALEN - 1). + /// + internal const int MaxNameLength = 63; + + /// + public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options) + { + var failures = new List(); + 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 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(); +} diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs index 40e6e71..2918c95 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/DapperDataProtectionExtensions.cs @@ -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; @@ -30,12 +32,16 @@ public static IDataProtectionBuilder PersistKeysWithDapperInPostgreSQL(this IDat TableName = Constants.TableName }; configAction?.Invoke(config); - builder.Services.Configure(c => - { - c.InitializeTable = config.InitializeTable; - c.SchemaName = config.SchemaName; - c.TableName = config.TableName; - }); + builder.Services.AddOptions() + .Configure(c => + { + c.InitializeTable = config.InitializeTable; + c.SchemaName = config.SchemaName; + c.TableName = config.TableName; + }) + .ValidateOnStart(); + builder.Services.TryAddEnumerable(ServiceDescriptor + .Singleton, 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. diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs index babd271..d427a46 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL/PostgreSQLDataProtectionRepository.cs @@ -16,6 +16,7 @@ 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 @@ -23,7 +24,7 @@ private string GetAllQuery() => insert_date AS "{nameof(DataProtectionKeyEntity.InsertDate)}", friendly_name AS "{nameof(DataProtectionKeyEntity.FriendlyName)}", xml AS "{nameof(DataProtectionKeyEntity.Xml)}" - FROM {_config.SchemaName}.{_config.TableName}; + FROM {_tableName}; """; /// @@ -36,6 +37,7 @@ public PostgreSQLDataProtectionRepository([FromKeyedServices(Constants.DataSourc { _dataSource = dataSource; _config = options.Value; + _tableName = $"{QuoteIdentifier(_config.SchemaName)}.{QuoteIdentifier(_config.TableName)}"; } /// @@ -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 ( @@ -88,7 +90,7 @@ public void Delete(IReadOnlyCollection 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 }), @@ -108,6 +110,7 @@ private static List GetIds(IReadOnlyCollection keys) /// /// + /// 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. /// @@ -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( + "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("\"", "\"\"")}\""; } \ No newline at end of file diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs new file mode 100644 index 0000000..fbdde21 --- /dev/null +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; + +namespace AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer; + +/// +/// Validates the schema and table names of for SQL Server. +/// +internal sealed partial class DapperDataProtectionConfigValidator : IValidateOptions +{ + /// + /// Maximum length of a SQL Server identifier (sysname). + /// + internal const int MaxSchemaNameLength = 128; + + /// + /// Maximum table name length, which leaves room for the derived index name IX_{TableName}_FriendlyName. + /// + internal const int MaxTableNameLength = MaxSchemaNameLength - 16; + + /// + public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options) + { + var failures = new List(); + 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 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(); +} diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs index 4772cf9..5047386 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionExtensions.cs @@ -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; @@ -28,12 +30,16 @@ public static IDataProtectionBuilder PersistKeysWithDapperInSqlServer(this IData TableName = Constants.TableName }; configAction?.Invoke(config); - builder.Services.Configure(c => - { - c.InitializeTable = config.InitializeTable; - c.SchemaName = config.SchemaName; - c.TableName = config.TableName; - }); + builder.Services.AddOptions() + .Configure(c => + { + c.InitializeTable = config.InitializeTable; + c.SchemaName = config.SchemaName; + c.TableName = config.TableName; + }) + .ValidateOnStart(); + builder.Services.TryAddEnumerable(ServiceDescriptor + .Singleton, DapperDataProtectionConfigValidator>()); builder.Services.AddSingleton(_=>new SqlConnectionFactory(connectionString)); builder.Services.AddScoped(); diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs index 71c8021..1570694 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs @@ -14,6 +14,7 @@ public class SQLServerDataProtectionRepository : IDbDataProtectionStorage private const string InitializeDbLockResource = "AspNetCore.DataProtection.CustomStorage.InitializeDb"; private readonly SqlConnectionFactory _connectionFactory; private readonly DapperDataProtectionConfig _config; + private readonly string _tableName; private string GetAllQuery() => $""" @@ -22,7 +23,7 @@ private string GetAllQuery() => [{nameof(DataProtectionKeyEntity.InsertDate)}], [{nameof(DataProtectionKeyEntity.FriendlyName)}], [{nameof(DataProtectionKeyEntity.Xml)}] - FROM [{_config.SchemaName}].[{_config.TableName}]; + FROM {_tableName}; """; /// /// create an instance of @@ -34,6 +35,7 @@ public SQLServerDataProtectionRepository(SqlConnectionFactory connectionFactory, { _connectionFactory = connectionFactory; _config = options.Value; + _tableName = $"{QuoteIdentifier(_config.SchemaName)}.{QuoteIdentifier(_config.TableName)}"; } /// @@ -58,7 +60,7 @@ public void Insert(DataProtectionKey key) using var connection = _connectionFactory.CreateConnection(); connection.Execute( $""" - INSERT INTO [{_config.SchemaName}].[{_config.TableName}]( + INSERT INTO {_tableName}( {nameof(DataProtectionKeyEntity.FriendlyName)}, {nameof(DataProtectionKeyEntity.Xml)}) VALUES ( @@ -87,7 +89,7 @@ public void Delete(IReadOnlyCollection keys) // Dapper executes the statement once per id, in order. connection.Execute( $""" - DELETE FROM [{_config.SchemaName}].[{_config.TableName}] + DELETE FROM {_tableName} WHERE [{nameof(DataProtectionKeyEntity.Id)}] = @{nameof(DataProtectionKeyEntity.Id)} """, ids.Select(id => new { Id = id }), @@ -107,6 +109,7 @@ private static List GetIds(IReadOnlyCollection keys) /// /// + /// 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 an exclusive application lock held until the transaction ends. /// @@ -117,6 +120,8 @@ public void InitializeDb() connection.Open(); using var transaction = connection.BeginTransaction(); // Without the lock, concurrent existence checks can all pass and the later CREATE statements fail. + // Names are passed as parameters wherever SQL accepts them; elsewhere they are quoted identifiers. + // CREATE SCHEMA must be the only statement in its batch, hence the dynamic SQL. // The unique index is ensured independently of the table, so that an existing table // without the index (created manually, or with the index dropped) gets it restored. connection.Execute( @@ -125,34 +130,46 @@ public void InitializeDb() EXEC @lockResult = sp_getapplock @Resource = @LockResource, @LockMode = N'Exclusive', @LockOwner = N'Transaction'; IF @lockResult < 0 THROW 50000, N'Cannot acquire the application lock to initialize the data protection keys table.', 1; + IF SCHEMA_ID(@SchemaName) IS NULL + BEGIN + DECLARE @createSchema nvarchar(max) = N'CREATE SCHEMA ' + QUOTENAME(@SchemaName); + EXEC(@createSchema); + END; IF NOT EXISTS(SELECT t.[object_id] FROM [sys].[tables] t JOIN sys.schemas s ON s.schema_id=t.schema_id - WHERE t.[name] = N'{_config.TableName}' AND s.name=N'{_config.SchemaName}') + WHERE t.[name] = @TableName AND s.name = @SchemaName) BEGIN - CREATE TABLE [{_config.SchemaName}].[{_config.TableName}]( + CREATE TABLE {_tableName}( [{nameof(DataProtectionKeyEntity.Id)}] [int] IDENTITY(1,1) NOT NULL, - [{nameof(DataProtectionKeyEntity.InsertDate)}] [datetime2] NOT NULL CONSTRAINT [DF_{_config.TableName}_{nameof(DataProtectionKeyEntity.InsertDate)}] DEFAULT sysutcdatetime(), + [{nameof(DataProtectionKeyEntity.InsertDate)}] [datetime2] NOT NULL CONSTRAINT {QuoteIdentifier($"DF_{_config.TableName}_{nameof(DataProtectionKeyEntity.InsertDate)}")} DEFAULT sysutcdatetime(), [{nameof(DataProtectionKeyEntity.FriendlyName)}] [nvarchar](256) NULL, [{nameof(DataProtectionKeyEntity.Xml)}] [nvarchar](max) NOT NULL, - CONSTRAINT [PK_{_config.TableName}] PRIMARY KEY CLUSTERED ([{nameof(DataProtectionKeyEntity.Id)}] ASC) ON [PRIMARY] + CONSTRAINT {QuoteIdentifier($"PK_{_config.TableName}")} PRIMARY KEY CLUSTERED ([{nameof(DataProtectionKeyEntity.Id)}] ASC) ON [PRIMARY] ) ON [PRIMARY]; END; IF NOT EXISTS(SELECT i.[index_id] FROM [sys].[indexes] i - WHERE i.[object_id] = OBJECT_ID(N'[{_config.SchemaName}].[{_config.TableName}]') AND i.[name] = N'{indexName}') + WHERE i.[object_id] = OBJECT_ID(QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName)) AND i.[name] = @IndexName) BEGIN IF EXISTS(SELECT [{nameof(DataProtectionKeyEntity.FriendlyName)}] - FROM [{_config.SchemaName}].[{_config.TableName}] + FROM {_tableName} WHERE [{nameof(DataProtectionKeyEntity.FriendlyName)}] IS NOT NULL GROUP BY [{nameof(DataProtectionKeyEntity.FriendlyName)}] HAVING COUNT(*) > 1) - THROW 50000, N'Cannot create unique index [{indexName}] on [{_config.SchemaName}].[{_config.TableName}]: the table contains duplicate non-null [{nameof(DataProtectionKeyEntity.FriendlyName)}] values. Remove the duplicates and initialize again.', 1; - CREATE UNIQUE NONCLUSTERED INDEX [{indexName}] ON [{_config.SchemaName}].[{_config.TableName}]([{nameof(DataProtectionKeyEntity.FriendlyName)}] ASC) WHERE [{nameof(DataProtectionKeyEntity.FriendlyName)}] IS NOT NULL ON [PRIMARY]; + BEGIN + DECLARE @message nvarchar(2048) = N'Cannot create unique index ' + QUOTENAME(@IndexName) + + N' on ' + QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName) + + N': the table contains duplicate non-null [{nameof(DataProtectionKeyEntity.FriendlyName)}] values. Remove the duplicates and initialize again.'; + THROW 50000, @message, 1; + END; + CREATE UNIQUE NONCLUSTERED INDEX {QuoteIdentifier(indexName)} ON {_tableName}([{nameof(DataProtectionKeyEntity.FriendlyName)}] ASC) WHERE [{nameof(DataProtectionKeyEntity.FriendlyName)}] IS NOT NULL ON [PRIMARY]; END """, - new { LockResource = InitializeDbLockResource }, + new { LockResource = InitializeDbLockResource, _config.SchemaName, _config.TableName, IndexName = indexName }, transaction); transaction.Commit(); } + + private static string QuoteIdentifier(string name) => $"[{name.Replace("]", "]]")}]"; } \ No newline at end of file diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs index 88b932b..a1b71d9 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper/DapperDataProtectionConfig.cs @@ -8,14 +8,21 @@ public record DapperDataProtectionConfig /// /// schema name /// + /// + /// Validated at startup by the database provider: letters, digits and underscores, not starting with a digit + /// (lowercase only for PostgreSQL), within the database identifier length limit. + /// public required string SchemaName { get; set; } /// /// table name /// + /// + /// Validated at startup with the same rules as . + /// public required string TableName { get; set; } /// - /// if true at start it will create the table if not present + /// if true at start it will create the schema, the table and its unique index if not present /// Default=true /// public bool InitializeTable { get; set; } = true; diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionConfigValidatorTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionConfigValidatorTests.cs new file mode 100644 index 0000000..0ab82fb --- /dev/null +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionConfigValidatorTests.cs @@ -0,0 +1,82 @@ +using AspNetCore.DataProtection.CustomStorage.Dapper; +using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; +using FluentAssertions; +using Xunit; + +namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; + +public class PostgreSqlDapperDataProtectionConfigValidatorTests +{ + private readonly DapperDataProtectionConfigValidator _sut = new(); + + [Theory] + [InlineData(Constants.DefaultSchema, Constants.TableName)] + [InlineData("_schema1", "_keys_2")] + [InlineData("security", "user")] + public void Validate_ValidNames_Succeeds(string schemaName, string tableName) + { + var result = _sut.Validate(null, Config(schemaName, tableName)); + + result.Succeeded.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidSchemaName_FailsNamingTheOption(string? schemaName) + { + var result = _sut.Validate(null, Config(schemaName!, Constants.TableName)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.TableName)); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidTableName_FailsNamingTheOption(string? tableName) + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, tableName!)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.SchemaName)); + } + + [Fact] + public void Validate_MixedCaseName_FailsExplainingLowercaseRequirement() + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, "DataProtectionKeys")); + + result.FailureMessage.Should().Contain("lowercase"); + } + + [Theory] + [InlineData(63, true)] + [InlineData(64, false)] + public void Validate_NameLength_IsLimitedToPostgreSqlIdentifierLength(int length, bool succeeded) + { + var name = new string('n', length); + + _sut.Validate(null, Config(name, Constants.TableName)).Succeeded.Should().Be(succeeded); + _sut.Validate(null, Config(Constants.DefaultSchema, name)).Succeeded.Should().Be(succeeded); + } + + public static TheoryData InvalidNames() => + [ + null!, + "", + " ", + "1keys", + "my-keys", + "my keys", + "Keys", + "keys\"", + "keys'", + "public.keys", + "keys\n", + "chiav\u00e8" + ]; + + private static DapperDataProtectionConfig Config(string schemaName, string tableName) => + new() { SchemaName = schemaName, TableName = tableName }; +} diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs index 376c77a..31792de 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDapperDataProtectionExtensionsTests.cs @@ -1,9 +1,11 @@ using System; +using AspNetCore.DataProtection.CustomStorage.Dapper; using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; using Dapper; using FluentAssertions; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Npgsql; using Xunit; @@ -60,4 +62,40 @@ public void PersistKeysWithDapperInPostgreSQL_DoesNotEnableMatchNamesWithUndersc DefaultTypeMap.MatchNamesWithUnderscores.Should().BeFalse(); } + + [Fact] + public void PersistKeysWithDapperInPostgreSQL_DefaultConfig_PassesStartupValidation() + { + using var provider = BuildServiceProvider(_ => { }); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().NotThrow(); + } + + [Fact] + public void PersistKeysWithDapperInPostgreSQL_MixedCaseTableName_FailsStartupValidation() + { + using var provider = BuildServiceProvider(config => config.TableName = "DataProtectionKeys"); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}*"); + } + + [Fact] + public void UseDapperDataProtection_InvalidSchemaName_ThrowsBeforeTouchingTheDatabase() + { + using var provider = BuildServiceProvider(config => config.SchemaName = "my-schema"); + + provider.Invoking(x => x.UseDapperDataProtection()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}*"); + } + + private static ServiceProvider BuildServiceProvider(Action configAction) + { + var services = new ServiceCollection(); + services.AddDataProtection().PersistKeysWithDapperInPostgreSQL("Host=localhost;Database=test", configAction); + return services.BuildServiceProvider(); + } } diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs index d03de89..8c58431 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs @@ -183,6 +183,38 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() } } + [Fact] + public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() + { + var schemaName = $"schema_{Guid.NewGuid().ToString("N")[..12]}"; + var tableName = $"keys_{Guid.NewGuid().ToString("N")[..12]}"; + var sut = CreateSut(schemaName, tableName); + + sut.InitializeDb(); + sut.Insert(new DataProtectionKey { FriendlyName = "MissingSchemaKey", Xml = "Xml" }); + + CountRows(schemaName, tableName).Should().Be(1); + sut.Invoking(x => x.InitializeDb()).Should().NotThrow(); + } + + [Fact] + public void InitializeDb_ReservedWordSchemaName_PersistsKeys() + { + // A reserved word is accepted unquoted after a schema qualifier, but not as the schema name itself. + const string schemaName = "order"; + var tableName = $"keys_{Guid.NewGuid().ToString("N")[..12]}"; + var sut = CreateSut(schemaName, tableName); + var key = new DataProtectionKey { FriendlyName = "ReservedWordKey", Xml = "Xml" }; + + sut.InitializeDb(); + sut.Insert(key); + + var stored = sut.GetAll().Should().ContainSingle().Subject; + stored.FriendlyName.Should().Be(key.FriendlyName); + sut.Delete([stored]); + CountRows(schemaName, tableName).Should().Be(0); + } + [Fact] public void Delete_NullKeys_ThrowsArgumentNullException() { @@ -262,4 +294,17 @@ CREATE TRIGGER tr_{tableName}_fail_delete BEFORE DELETE ON {Constants.DefaultSch sut.GetAll().Select(x => x.FriendlyName).Should().BeEquivalentTo("DeleteFirst", "DeleteFails"); } + + private PostgreSQLDataProtectionRepository CreateSut(string schemaName, string tableName) + { + var options = Substitute.For>(); + options.Value.Returns(new DapperDataProtectionConfig { SchemaName = schemaName, TableName = tableName }); + return new PostgreSQLDataProtectionRepository(_fixture.NpgsqlDataSource, options); + } + + private long CountRows(string schemaName, string tableName) + { + using var connection = _fixture.NpgsqlDataSource.OpenConnection(); + return connection.ExecuteScalar($"""SELECT COUNT(*) FROM "{schemaName}"."{tableName}";"""); + } } diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs index 36b6634..8c9568f 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs @@ -87,7 +87,8 @@ public void Protect_ApplicationRegistersOwnDataSource_DataSourcesStaySeparate(bo Database = "application_database_does_not_exist" }.ConnectionString; using var applicationDataSource = NpgsqlDataSource.Create(applicationConnectionString); - using var services = BuildServiceProvider(tableName, applicationDataSource, applicationRegistersFirst); + using var services = BuildServiceProvider(tableName, + applicationDataSource: applicationDataSource, applicationRegistersFirst: applicationRegistersFirst); var protector = services.GetRequiredService().CreateProtector(Purpose); var protectedPayload = protector.Protect("payload"); @@ -97,10 +98,22 @@ public void Protect_ApplicationRegistersOwnDataSource_DataSourcesStaySeparate(bo GetStoredFriendlyNames(tableName).Should().ContainSingle(); } - // PostgreSQL folds unquoted identifiers to lower case; keep the name short enough for derived index names. + [Fact] + public void Protect_MissingReservedWordSchemaAndTable_StoresKeysInConfiguredTable() + { + using var services = BuildServiceProvider("order", "user"); + var protector = services.GetRequiredService().CreateProtector(Purpose); + + protector.Unprotect(protector.Protect("payload")).Should().Be("payload"); + + GetStoredFriendlyNames("order", "user").Should().ContainSingle(); + } + + // Names must be lowercase; keep the name short enough for derived index names. private static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; private ServiceProvider BuildServiceProvider(string tableName, + string schemaName = Constants.DefaultSchema, NpgsqlDataSource? applicationDataSource = null, bool applicationRegistersFirst = false) { @@ -112,7 +125,11 @@ private ServiceProvider BuildServiceProvider(string tableName, } services.AddDataProtection() .SetApplicationName(ApplicationName) - .PersistKeysWithDapperInPostgreSQL(_fixture.ConnectionString, config => config.TableName = tableName); + .PersistKeysWithDapperInPostgreSQL(_fixture.ConnectionString, config => + { + config.SchemaName = schemaName; + config.TableName = tableName; + }); if (applicationDataSource is not null && !applicationRegistersFirst) { services.AddSingleton(applicationDataSource); @@ -123,10 +140,10 @@ private ServiceProvider BuildServiceProvider(string tableName, return provider; } - private List GetStoredFriendlyNames(string tableName) + private List GetStoredFriendlyNames(string tableName, string schemaName = Constants.DefaultSchema) { using var connection = _fixture.NpgsqlDataSource.OpenConnection(); return connection.Query( - $"SELECT friendly_name FROM {Constants.DefaultSchema}.{tableName};").AsList(); + $"""SELECT friendly_name FROM "{schemaName}"."{tableName}";""").AsList(); } } diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionConfigValidatorTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionConfigValidatorTests.cs new file mode 100644 index 0000000..a133a7c --- /dev/null +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionConfigValidatorTests.cs @@ -0,0 +1,91 @@ +using AspNetCore.DataProtection.CustomStorage.Dapper; +using AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer; +using FluentAssertions; +using Xunit; + +namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; + +public class SqlServerDapperDataProtectionConfigValidatorTests +{ + private readonly DapperDataProtectionConfigValidator _sut = new(); + + [Theory] + [InlineData(Constants.DefaultSchema, Constants.TableName)] + [InlineData("_Schema1", "_Keys_2")] + [InlineData("Security", "Order")] + public void Validate_ValidNames_Succeeds(string schemaName, string tableName) + { + var result = _sut.Validate(null, Config(schemaName, tableName)); + + result.Succeeded.Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidSchemaName_FailsNamingTheOption(string? schemaName) + { + var result = _sut.Validate(null, Config(schemaName!, Constants.TableName)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.TableName)); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Validate_InvalidTableName_FailsNamingTheOption(string? tableName) + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, tableName!)); + + result.Failed.Should().BeTrue(); + result.FailureMessage.Should().Contain($"{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}") + .And.NotContain(nameof(DapperDataProtectionConfig.SchemaName)); + } + + [Fact] + public void Validate_BothNamesInvalid_ReportsBoth() + { + var result = _sut.Validate(null, Config("my schema", "my-keys")); + + result.Failures.Should().HaveCount(2); + } + + [Theory] + [InlineData(128, true)] + [InlineData(129, false)] + public void Validate_SchemaNameLength_IsLimitedToSqlServerIdentifierLength(int length, bool succeeded) + { + var result = _sut.Validate(null, Config(new string('s', length), Constants.TableName)); + + result.Succeeded.Should().Be(succeeded); + } + + [Theory] + [InlineData(112, true)] + [InlineData(113, false)] + public void Validate_TableNameLength_LeavesRoomForDerivedIndexName(int length, bool succeeded) + { + var result = _sut.Validate(null, Config(Constants.DefaultSchema, new string('t', length))); + + result.Succeeded.Should().Be(succeeded); + } + + public static TheoryData InvalidNames() => + [ + null!, + "", + " ", + "1Keys", + "My-Keys", + "My Keys", + "Keys]", + "Keys'", + "Keys\"", + "dbo.Keys", + "Keys\n", + "Chiav\u00e8" + ]; + + private static DapperDataProtectionConfig Config(string schemaName, string tableName) => + new() { SchemaName = schemaName, TableName = tableName }; +} diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionExtensionsTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionExtensionsTests.cs new file mode 100644 index 0000000..010fedb --- /dev/null +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDapperDataProtectionExtensionsTests.cs @@ -0,0 +1,51 @@ +using System; +using AspNetCore.DataProtection.CustomStorage.Dapper; +using AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer; +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; + +public class SqlServerDapperDataProtectionExtensionsTests +{ + private const string ConnectionString = "Server=localhost;Database=test"; + + [Fact] + public void PersistKeysWithDapperInSqlServer_DefaultConfig_PassesStartupValidation() + { + using var provider = BuildServiceProvider(_ => { }); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().NotThrow(); + } + + [Fact] + public void PersistKeysWithDapperInSqlServer_InvalidTableName_FailsStartupValidation() + { + using var provider = BuildServiceProvider(config => config.TableName = "My-Keys"); + + provider.GetRequiredService().Invoking(x => x.Validate()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.TableName)}*"); + } + + [Fact] + public void UseDapperDataProtection_InvalidSchemaName_ThrowsBeforeTouchingTheDatabase() + { + using var provider = BuildServiceProvider(config => config.SchemaName = ""); + + provider.Invoking(x => x.UseDapperDataProtection()) + .Should().Throw() + .WithMessage($"*{nameof(DapperDataProtectionConfig)}.{nameof(DapperDataProtectionConfig.SchemaName)}*"); + } + + private static ServiceProvider BuildServiceProvider(Action configAction) + { + var services = new ServiceCollection(); + services.AddDataProtection().PersistKeysWithDapperInSqlServer(ConnectionString, configAction); + return services.BuildServiceProvider(); + } +} diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs index ee13160..731f152 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs @@ -307,6 +307,20 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() } } + [Fact] + public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() + { + var schemaName = $"Schema_{Guid.NewGuid():N}"; + var tableName = NewTableName(); + var sut = CreateSut(tableName, schemaName); + + sut.InitializeDb(); + sut.Insert(new DataProtectionKey { FriendlyName = "MissingSchemaKey", Xml = "Xml" }); + + CountRows(schemaName, tableName).Should().Be(1); + sut.Invoking(x => x.InitializeDb()).Should().NotThrow(); + } + private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; private SQLServerDataProtectionRepository CreateSut(string tableName, string schemaName = Constants.DefaultSchema) diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs index 80cbfb2..eea116e 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using AspNetCore.DataProtection.CustomStorage.Dapper; @@ -74,25 +74,41 @@ public void DeleteKeys_KeySelected_DeletesItFromTable() GetStoredFriendlyNames(tableName).Should().Equal($"key-{kept.KeyId:D}"); } + [Fact] + public void Protect_MissingCustomSchemaAndReservedWordTable_StoresKeysInConfiguredTable() + { + var schemaName = $"Schema_{Guid.NewGuid():N}"; + using var services = BuildServiceProvider("Order", schemaName); + var protector = services.GetRequiredService().CreateProtector(Purpose); + + protector.Unprotect(protector.Protect("payload")).Should().Be("payload"); + + GetStoredFriendlyNames("Order", schemaName).Should().ContainSingle(); + } + private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; - private ServiceProvider BuildServiceProvider(string tableName) + private ServiceProvider BuildServiceProvider(string tableName, string schemaName = Constants.DefaultSchema) { var services = new ServiceCollection(); services.AddSingleton(NullLoggerFactory.Instance); services.AddDataProtection() .SetApplicationName(ApplicationName) - .PersistKeysWithDapperInSqlServer(_fixture.ConnectionString, config => config.TableName = tableName); + .PersistKeysWithDapperInSqlServer(_fixture.ConnectionString, config => + { + config.SchemaName = schemaName; + config.TableName = tableName; + }); var provider = services.BuildServiceProvider(); provider.UseDapperDataProtection(); return provider; } - private List GetStoredFriendlyNames(string tableName) + private List GetStoredFriendlyNames(string tableName, string schemaName = Constants.DefaultSchema) { using var connection = _fixture.SqlConnectionFactory.CreateConnection(); return connection.Query( - $"SELECT [{nameof(DataProtectionKeyEntity.FriendlyName)}] FROM [{Constants.DefaultSchema}].[{tableName}];").AsList(); + $"SELECT [{nameof(DataProtectionKeyEntity.FriendlyName)}] FROM [{schemaName}].[{tableName}];").AsList(); } }