feat: add audited one-shot prepare gate

This commit is contained in:
2026-07-18 12:29:09 +09:00
parent 1302b1b92f
commit 5379511b8a
25 changed files with 6188 additions and 41 deletions

View File

@@ -0,0 +1,100 @@
#nullable enable
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Identifies the native database-write boundary that is requesting
/// authorization. The request deliberately contains no operator-entered data.
/// </summary>
public enum DatabaseMutationTarget
{
OperatorCatalog,
NamedPlaylist,
ManualFinancial
}
/// <summary>
/// Describes one validated transaction immediately before its executor can
/// create a database connection.
/// </summary>
public sealed record DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget Target,
DataSourceKind Source,
Guid OperationId,
string MutationKind);
/// <summary>
/// Final authorization seam for state-changing database operations. Returning
/// false denies the transaction before a connection or transaction is created.
/// Throwing also fails closed and is never converted into an allow decision.
/// </summary>
public interface IDatabaseMutationAuthorization
{
bool IsAuthorized(DatabaseMutationAuthorizationRequest request);
}
/// <summary>
/// Provides the compatibility policy used when existing constructors do not
/// supply an explicit database-mutation authorization policy.
/// </summary>
public static class DatabaseMutationAuthorization
{
public static IDatabaseMutationAuthorization AllowAll { get; } =
new AllowAllDatabaseMutationAuthorization();
private sealed class AllowAllDatabaseMutationAuthorization
: IDatabaseMutationAuthorization
{
public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return true;
}
}
}
/// <summary>
/// Raised when the active policy denies a database mutation before any native
/// database connection is created.
/// </summary>
public sealed class DatabaseMutationAuthorizationException : InvalidOperationException
{
public DatabaseMutationAuthorizationException(
DatabaseMutationAuthorizationRequest request,
Exception? innerException = null)
: base(
$"The {request?.Target} database mutation was denied before a connection was opened.",
innerException)
{
Request = request ?? throw new ArgumentNullException(nameof(request));
}
public DatabaseMutationAuthorizationRequest Request { get; }
}
internal static class DatabaseMutationAuthorizationGuard
{
internal static void Demand(
IDatabaseMutationAuthorization authorization,
DatabaseMutationAuthorizationRequest request)
{
ArgumentNullException.ThrowIfNull(authorization);
ArgumentNullException.ThrowIfNull(request);
bool authorized;
try
{
authorized = authorization.IsAuthorized(request);
}
catch (Exception exception)
{
throw new DatabaseMutationAuthorizationException(request, exception);
}
if (!authorized)
{
throw new DatabaseMutationAuthorizationException(request);
}
}
}

View File

@@ -17,6 +17,7 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly Action<DataSourceKind, DbCommand> _configureCommand;
@@ -28,7 +29,22 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureProviderCommand)
ConfigureProviderCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
public OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector,
IDatabaseMutationAuthorization mutationAuthorization)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureProviderCommand,
mutationAuthorization)
{
}
@@ -37,6 +53,21 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DataSourceKind, DbCommand> configureCommand)
: this(
connectionFactory,
resilienceOptions,
errorDetector,
configureCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
internal OperatorCatalogMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DataSourceKind, DbCommand> configureCommand,
IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
@@ -44,6 +75,8 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
_mutationAuthorization = mutationAuthorization ??
throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -56,6 +89,23 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
{
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
try
{
DatabaseMutationAuthorizationGuard.Demand(
_mutationAuthorization,
new DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget.OperatorCatalog,
transaction.Source,
transaction.OperationId,
transaction.Kind.ToString()));
}
catch (DatabaseMutationAuthorizationException exception)
{
throw new OperatorCatalogMutationException(
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
outcomeUnknown: false,
exception);
}
var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source);
if (commandTimeoutSeconds is < 1 or > 600)
{

View File

@@ -19,6 +19,7 @@ public sealed partial class OracleManualFinancialMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
@@ -31,7 +32,22 @@ public sealed partial class OracleManualFinancialMutationExecutor
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
ConfigureOracleCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
public OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector,
IDatabaseMutationAuthorization mutationAuthorization)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand,
mutationAuthorization)
{
}
@@ -40,12 +56,29 @@ public sealed partial class OracleManualFinancialMutationExecutor
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
: this(
connectionFactory,
resilienceOptions,
errorDetector,
configureCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
internal OracleManualFinancialMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand,
IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand));
_mutationAuthorization = mutationAuthorization ??
throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -73,6 +106,23 @@ public sealed partial class OracleManualFinancialMutationExecutor
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
try
{
DatabaseMutationAuthorizationGuard.Demand(
_mutationAuthorization,
new DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget.ManualFinancial,
DataSourceKind.Oracle,
transaction.OperationId,
transaction.Kind.ToString()));
}
catch (DatabaseMutationAuthorizationException exception)
{
throw new ManualFinancialMutationException(
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
outcomeUnknown: false,
exception);
}
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;

View File

@@ -17,6 +17,7 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action<DbCommand> _configureCommand;
@@ -29,7 +30,22 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand)
ConfigureOracleCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
public OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector? errorDetector,
IDatabaseMutationAuthorization mutationAuthorization)
: this(
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
ConfigureOracleCommand,
mutationAuthorization)
{
}
@@ -38,6 +54,21 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand)
: this(
connectionFactory,
resilienceOptions,
errorDetector,
configureCommand,
DatabaseMutationAuthorization.AllowAll)
{
}
internal OracleNamedPlaylistMutationExecutor(
IDatabaseConnectionFactory connectionFactory,
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action<DbCommand> configureCommand,
IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
@@ -45,6 +76,8 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
_mutationAuthorization = mutationAuthorization ??
throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -72,6 +105,23 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
try
{
DatabaseMutationAuthorizationGuard.Demand(
_mutationAuthorization,
new DatabaseMutationAuthorizationRequest(
DatabaseMutationTarget.NamedPlaylist,
DataSourceKind.Oracle,
transaction.OperationId,
transaction.Kind.ToString()));
}
catch (DatabaseMutationAuthorizationException exception)
{
throw new NamedPlaylistMutationException(
$"The {transaction.Kind} transaction was denied before it started and did not commit.",
outcomeUnknown: false,
exception);
}
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);