diff --git a/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs b/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs index 431e5c7c..5e2cc25d 100644 --- a/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs +++ b/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs @@ -236,7 +236,8 @@ namespace Microsoft.Extensions.DependencyInjection builder.Entity() .HasMany(application => application.Tokens) .WithOptional(token => token.Authorization) - .Map(association => association.MapKey("AuthorizationId")); + .Map(association => association.MapKey("AuthorizationId")) + .WillCascadeOnDelete(); builder.Entity() .ToTable("OpenIddictAuthorizations"); diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs index f2cbeb4a..46ef278d 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Data.Entity; using System.Linq; using System.Threading; @@ -161,6 +162,19 @@ namespace OpenIddict.EntityFramework throw new ArgumentNullException(nameof(application)); } + DbContextTransaction CreateTransaction() + { + try + { + return Context.Database.BeginTransaction(IsolationLevel.Serializable); + } + + catch + { + return null; + } + } + Task> ListAuthorizationsAsync() => (from authorization in Authorizations.Include(authorization => authorization.Tokens) where authorization.Application.Id.Equals(application.Id) @@ -172,27 +186,34 @@ namespace OpenIddict.EntityFramework where token.Application.Id.Equals(application.Id) select token).ToListAsync(cancellationToken); - // Remove all the authorizations associated with the application and - // the tokens attached to these implicit or explicit authorizations. - foreach (var authorization in await ListAuthorizationsAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = CreateTransaction()) { - foreach (var token in authorization.Tokens) + // Remove all the authorizations associated with the application and + // the tokens attached to these implicit or explicit authorizations. + foreach (var authorization in await ListAuthorizationsAsync()) + { + foreach (var token in authorization.Tokens) + { + Tokens.Remove(token); + } + + Authorizations.Remove(authorization); + } + + // Remove all the tokens associated with the application. + foreach (var token in await ListTokensAsync()) { Tokens.Remove(token); } - Authorizations.Remove(authorization); - } + Applications.Remove(application); - // Remove all the tokens associated with the application. - foreach (var token in await ListTokensAsync()) - { - Tokens.Remove(token); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); } - - Applications.Remove(application); - - await Context.SaveChangesAsync(cancellationToken); } /// diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs index 9f614ba7..0fca5218 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs @@ -162,20 +162,40 @@ namespace OpenIddict.EntityFramework throw new ArgumentNullException(nameof(authorization)); } + DbContextTransaction CreateTransaction() + { + try + { + return Context.Database.BeginTransaction(IsolationLevel.Serializable); + } + + catch + { + return null; + } + } + Task> ListTokensAsync() => (from token in Tokens where token.Authorization.Id.Equals(authorization.Id) select token).ToListAsync(cancellationToken); - // Remove all the tokens associated with the authorization. - foreach (var token in await ListTokensAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = CreateTransaction()) { - Tokens.Remove(token); - } + // Remove all the tokens associated with the authorization. + foreach (var token in await ListTokensAsync()) + { + Tokens.Remove(token); + } - Authorizations.Remove(authorization); + Authorizations.Remove(authorization); - await Context.SaveChangesAsync(cancellationToken); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } } /// @@ -347,6 +367,10 @@ namespace OpenIddict.EntityFramework break; } + // Note: new tokens may be attached after the authorizations were retrieved + // from the database since the transaction level is deliberately limited to + // repeatable read instead of serializable for performance reasons). In this + // case, the operation will fail, which is considered an acceptable risk. Authorizations.RemoveRange(authorizations); Tokens.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens)); diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs index a18074a7..0cca4c1c 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs @@ -264,7 +264,8 @@ namespace Microsoft.Extensions.DependencyInjection entity.HasMany(authorization => authorization.Tokens) .WithOne(token => token.Authorization) .HasForeignKey("AuthorizationId") - .IsRequired(required: false); + .IsRequired(required: false) + .OnDelete(DeleteBehavior.Cascade); entity.ToTable("OpenIddictAuthorizations"); }); diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs index 7aa4db13..8cc655e4 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs @@ -7,11 +7,14 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Caching.Memory; using OpenIddict.Core; using OpenIddict.Models; @@ -161,6 +164,29 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(application)); } + async Task CreateTransactionAsync() + { + // Note: transactions that specify an explicit isolation level are only supported by + // relational providers and trying to use them with a different provider results in + // an invalid operation exception being thrown at runtime. To prevent that, a manual + // check is made to ensure the underlying transaction manager is relational. + var manager = Context.Database.GetService(); + if (manager is IRelationalTransactionManager) + { + try + { + return await Context.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + } + + catch + { + return null; + } + } + + return null; + } + // Note: due to a bug in Entity Framework Core's query visitor, the authorizations can't be // filtered using authorization.Application.Id.Equals(key). To work around this issue, // this local method uses an explicit join before applying the equality check. @@ -184,27 +210,34 @@ namespace OpenIddict.EntityFrameworkCore where element.Id.Equals(application.Id) select token).ToListAsync(cancellationToken); - // Remove all the authorizations associated with the application and - // the tokens attached to these implicit or explicit authorizations. - foreach (var authorization in await ListAuthorizationsAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = await CreateTransactionAsync()) { - foreach (var token in authorization.Tokens) + // Remove all the authorizations associated with the application and + // the tokens attached to these implicit or explicit authorizations. + foreach (var authorization in await ListAuthorizationsAsync()) + { + foreach (var token in authorization.Tokens) + { + Context.Remove(token); + } + + Context.Remove(authorization); + } + + // Remove all the tokens associated with the application. + foreach (var token in await ListTokensAsync()) { Context.Remove(token); } - Context.Remove(authorization); - } + Context.Remove(application); - // Remove all the tokens associated with the application. - foreach (var token in await ListTokensAsync()) - { - Context.Remove(token); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); } - - Context.Remove(application); - - await Context.SaveChangesAsync(cancellationToken); } /// diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs index 850555e1..5b0ed0ed 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs @@ -164,6 +164,29 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(authorization)); } + async Task CreateTransactionAsync() + { + // Note: transactions that specify an explicit isolation level are only supported by + // relational providers and trying to use them with a different provider results in + // an invalid operation exception being thrown at runtime. To prevent that, a manual + // check is made to ensure the underlying transaction manager is relational. + var manager = Context.Database.GetService(); + if (manager is IRelationalTransactionManager) + { + try + { + return await Context.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + } + + catch + { + return null; + } + } + + return null; + } + // Note: due to a bug in Entity Framework Core's query visitor, the tokens can't be // filtered using token.Application.Id.Equals(key). To work around this issue, // this local method uses an explicit join before applying the equality check. @@ -175,15 +198,22 @@ namespace OpenIddict.EntityFrameworkCore where element.Id.Equals(authorization.Id) select token).ToListAsync(cancellationToken); - // Remove all the tokens associated with the authorization. - foreach (var token in await ListTokensAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = await CreateTransactionAsync()) { - Context.Remove(token); - } + // Remove all the tokens associated with the authorization. + foreach (var token in await ListTokensAsync()) + { + Context.Remove(token); + } - Context.Remove(authorization); + Context.Remove(authorization); - await Context.SaveChangesAsync(cancellationToken); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } } /// @@ -511,6 +541,10 @@ namespace OpenIddict.EntityFrameworkCore break; } + // Note: new tokens may be attached after the authorizations were retrieved + // from the database since the transaction level is deliberately limited to + // repeatable read instead of serializable for performance reasons). In this + // case, the operation will fail, which is considered an acceptable risk. Context.RemoveRange(authorizations); Context.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens));