System.InvalidOperationException: 'Unable to track an entity of type 'IdentityUserLogin<string>' because its primary key property 'TenantId' is null.'
Discussed in https://github.com/Finbuckle/Finbuckle.MultiTenant/discussions/879
Originally posted by MayurPatel-BlobStation October 4, 2024
"When I attempt to insert data into the AspNetUserLogin table, I encounter the following error: System.InvalidOperationException: 'Unable to track an entity of type 'IdentityUserLogin
var userResult = await _userManager.FindByEmailAsync("[email protected]"); var result = await _userManager.AddLoginAsync(userResult, externalLoginInfo);
Thanks I am looking into it. Does this happen during the third party sign in login flow or are you inserting into the table somewhere else in your code?
Thanks I am looking into it. Does this happen during the third party sign in login flow or are you inserting into the table somewhere else in your code?
Yes, In application I am implementing google and Microsoft SSO login, From UI side I have received Google token, so first I have check token is verified or not, if token is verified then check Login exist or not with
var result = await _userManager.FindByLoginAsync(loginProvider,providerKey);
If no login exist then inserting data in AspNetUser table then AspNetUserLogin table. but in AspNetUser data inserted successfully but AspNetUserLogin cause issue.
Are you using Asp.net Identity? Also can you please post what your Finbuckle setup looks like in your program.cs file?
Yes I have use Asp.net identity.
program.cs file code :
----------Program.cs----------
builder.Services.AddAppServices(configuration);
var app = builder.Build();
app.UseMultiTenant();
----------ServiceCollectionExtensions.cs----------
public static class ServiceCollectionExtensions
{
public static void AddAppServices(this IServiceCollection services, ApiConfiguration configuration)
{
services.AddSubscriptionManagmentDbContext(configuration!.DbSettings);
services.AddMultiTenantStoreDbContext(configuration!.DbSettings);
services.AddMultiTenant<TenantInfo>()
.WithRouteStrategy("TenantId")
.WithHeaderStrategy("TenantIdKey")
.WithPerTenantAuthentication()
.WithEFCoreStore<MultiTenantStoreDbContext, TenantInfo>();
}
public static void AddSubscriptionManagmentDbContext(this IServiceCollection services, DbSettings dbSettings)
{
NpgsqlConnection.GlobalTypeMapper.EnableDynamicJson();
services.AddDbContext<SubscriptionManagmentDbContext>(opts =>
{
opts.UseNpgsql(Convert.ToString(dbSettings.ConnectionString));
opts.ConfigureWarnings(w => w.Ignore(RelationalEventId.MultipleCollectionIncludeWarning));
});
}
public static void AddMultiTenantStoreDbContext(this IServiceCollection services, DbSettings dbSettings)
{
services.AddDbContext<MultiTenantStoreDbContext>(opts =>
{
opts.UseNpgsql(Convert.ToString(dbSettings.ConnectionString));
opts.ConfigureWarnings(w => w.Ignore(RelationalEventId.MultipleCollectionIncludeWarning));
});
}
}
----------MultiTenantStoreDbContext.cs----------
public class MultiTenantStoreDbContext : EFCoreStoreDbContext<TenantInfo> { public MultiTenantStoreDbContext(DbContextOptions options) : base(options){} }
----------SubscriptionManagmentDbContext.cs----------
public class SubscriptionManagmentDbContext : MultiTenantIdentityDbContext
{
private readonly IMultiTenantContextAccessor _multiTenantContextAccessor;
protected readonly IHttpContextAccessor _httpContextAccessor;
public SubscriptionManagmentDbContext(DbContextOptions<SubscriptionManagmentDbContext> options,
IMultiTenantContextAccessor multiTenantContextAccessor
, IHttpContextAccessor httpContextAccessor) : base(multiTenantContextAccessor, options)
{
_multiTenantContextAccessor = multiTenantContextAccessor;
_httpContextAccessor = httpContextAccessor;
}
public SubscriptionManagmentDbContext(IMultiTenantContextAccessor multiTenantContextAccessor) : base(multiTenantContextAccessor)
{}
public SubscriptionManagmentDbContext(ITenantInfo tenantInfo, DbContextOptions<SubscriptionManagmentDbContext> options) :
base(tenantInfo, options){}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}
Thanks. I don’t see any issues with this code. Where exactly in your app is AddLoginAsync getting called?
I have created a repository called IdentityRepository, which I am currently using.
Repository Code:
public class IdentityRepository : IIdentityRepository
{
private readonly UserManager<IdentityUser> _userManager;
private readonly SignInManager<IdentityUser> _signInManager;
public IdentityRepository(
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager,
)
{
_userManager = userManager;
_signInManager = signInManager;
}
public async Domains.IdentityUser AddLoginAsync(Domains.IdentityUser users, UserLoginInfo externalLoginInfo)
{
try
{
var insertUserModel = _mapper.Map<IdentityUser>(users);
var result = await _userManager.AddLoginAsync(insertUserModel, externalLoginInfo);
return _mapper.Map<Domains.IdentityUser>(insertUserModel);
}
catch(){}
}
}
Is your identity repository registered in DI? What is the lifetime? The user manager internally will have a user store which internally uses your dbcontext which was instantiated for the current tenant. If you also inject IMultitenantAccessor into this class does it resolve the correct tenant right before the add user login call?
I have registered the identity repository in dependency injection with a scoped lifetime. However, I cannot find IMultitenantAccessor, which I believe refers to IMultiTenantContextAccessor. Even after using this, it is still not working.
This issue has been labeled inactive because it has been open 180 days with no activity. Please consider closing this issue if no further action is needed.
Points to consider:
- in the MultiTenantIdentityDbContext the PK is changed to include TenantId
see
MultiTenantIdentityDbContext<TUser, TRole, TKey>builder.Entity<IdentityUserLogin<TKey>>().IsMultiTenant().AdjustUniqueIndexes().AdjustKeys(builder); - UserStore creates IdentityUserLogin internally and adds it to the DbContext. TenantId is null at this stage thats why DbContext Add fails
A quick solution is to override Create... methods of the UserStore and RoleStore to ensure that TenantId is specified.
services.AddScoped<IRoleStore<IdentityRole>, MultiTenantRoleStore>();
services.AddScoped<IUserStore<IdentityUser>, MultiTenantUserStore>();
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<MyMultiTenantIdentityDbContext>()
.AddDefaultTokenProviders();
public sealed class MultiTenantUserStore : UserStore<IdentityUser>
{
private readonly MyMultiTenantIdentityDbContext _context;
public MultiTenantUserStore(
MyMultiTenantIdentityDbContext context,
IdentityErrorDescriber describer = null)
: base(context, describer)
{
ArgumentNullException.ThrowIfNull(context, nameof(context));
_context = context;
}
protected override IdentityUserClaim<string> CreateUserClaim(IdentityUser user, Claim claim)
{
return _context.EnforceMultiTenant(
base.CreateUserClaim(user, claim));
}
protected override IdentityUserRole<string> CreateUserRole(IdentityUser user, IdentityRole role)
{
return _context.EnforceMultiTenant(
base.CreateUserRole(user, role));
}
protected override IdentityUserToken<string> CreateUserToken(IdentityUser user, string loginProvider, string name, string value)
{
return _context.EnforceMultiTenant(
base.CreateUserToken(user, loginProvider, name, value));
}
protected override IdentityUserLogin<string> CreateUserLogin(IdentityUser user, UserLoginInfo login)
{
return _context.EnforceMultiTenant(
base.CreateUserLogin(user, login));
}
}
public sealed class MultiTenantRoleStore : RoleStore<IdentityRole>
{
private readonly MyMultiTenantIdentityDbContext _context;
public MultiTenantRoleStore(MyMultiTenantIdentityDbContext context, IdentityErrorDescriber describer = null)
: base(context, describer)
{
ArgumentNullException.ThrowIfNull(context, nameof(context));
_context = context;
}
protected override IdentityRoleClaim<string> CreateRoleClaim(IdentityRole role, Claim claim)
{
return _context.EnforceMultiTenant(
base.CreateRoleClaim(role, claim));
}
}
internal static class MultiTenantIdentityDbContextExtensions
{
private const string TENANT_ID = "TenantId";
internal static T EnforceMultiTenant<T>(this MultiTenantIdentityDbContext context, T entity)
{
var entry = context.Entry(entity);
if (!entry.Metadata.IsMultiTenant())
{
return entity;
}
var tenantProperty = entry.Property(TENANT_ID);
var actualTenant = tenantProperty.CurrentValue;
var expectedTenant = context.TenantInfo.Id;
if (actualTenant is null)
{
tenantProperty.CurrentValue = expectedTenant;
return entity;
}
if (actualTenant.Equals(context.TenantInfo.Id))
{
return entity;
}
switch (context.TenantMismatchMode)
{
case TenantMismatchMode.Ignore:
{
return entity;
}
case TenantMismatchMode.Overwrite:
{
tenantProperty.CurrentValue = expectedTenant;
return entity;
}
case TenantMismatchMode.Throw:
{
throw new MultiTenantException($"Tenant mismatch. Expected {expectedTenant}. Actual {actualTenant}");
}
default:
{
throw new NotImplementedException($"TenantMismatchMode {context.TenantMismatchMode}. Expected {expectedTenant}. Actual {actualTenant}");
}
}
}
}
hi @ekallaur that is a good approach. In the right settings the regular user store should work though. Another option is to set TenantNotSet mode to overwrite in which case the db context will set the tenant correctly. Depending on where exactly the user store is called from it can be tricky to ensure the tenant is set correctly when the dbcontext saves the changes, but for most normal situations it is doable.
hi @ekallaur that is a good approach. In the right settings the regular user store should work though. Another option is to set
TenantNotSetmode to overwrite in which case the db context will set the tenant correctly. Depending on where exactly the user store is called from it can be tricky to ensure the tenant is set correctly when the dbcontext saves the changes, but for most normal situations it is doable.
hi @AndrewTriesToCode TenantNotSet is used in the EnforceMultiTenant during SaveChanges or SaveChangesAsync, but this error happens much earlier - during Set<T>().Add(entity) when entity has TenantId as a part of PK and TenantId is null. see UserStore.AddLoginAsync.
The full stack track for clarity of the original issue:
System.InvalidOperationException: Unable to track an entity of type 'IdentityUserLogin<string>' because its primary key property 'TenantId' is null.
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NullableKeyIdentityMap`1.Add(InternalEntityEntry entry)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.StartTracking(InternalEntityEntry entry)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.SetEntityState(EntityState oldState, EntityState newState, Boolean acceptChanges, Boolean modifyProperties)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.SetEntityState(EntityState entityState, Boolean acceptChanges, Boolean modifyProperties, Nullable`1 forceStateWhenUnknownKey, Nullable`1 fallbackState)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityGraphAttacher.PaintAction(EntityEntryGraphNode`1 node)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityEntryGraphIterator.TraverseGraph[TState](EntityEntryGraphNode`1 node, Func`2 handleNode)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.EntityGraphAttacher.AttachGraph(InternalEntityEntry rootEntry, EntityState targetState, EntityState storeGeneratedWithKeySetTargetState, Boolean forceStateWhenUnknownKey)
at Microsoft.EntityFrameworkCore.Internal.InternalDbSet`1.SetEntityState(InternalEntityEntry entry, EntityState entityState)
at Microsoft.EntityFrameworkCore.Internal.InternalDbSet`1.Add(TEntity entity)
at Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore`9.AddLoginAsync(TUser user, UserLoginInfo login, CancellationToken cancellationToken)
at Microsoft.AspNetCore.Identity.UserManager`1.AddLoginAsync(TUser user, UserLoginInfo login)
at ThisIsMyProject.AndThisIsMyMethod(ExternalUser user) in PathToFile.cs:line 45
Yeah you are right. I’m looking further into this.
Ok after digging in I have come to the conclusion that adding the PK to that table is more trouble than it is worth. I’ll put out a new release to remove that this week. The original idea was that table doesn’t have a guid ID like the others but after examining how it is used by UserManager and UserStore I don’t think it’s needed.
@AndrewTriesToCode just for the record:
- when UserLogin PK has no TenantId and the same external IdP is used for different tenants, then the error occur during add UserLogin - duplicate PK.
@ekallaur that could be an issue but I examined the UserStore class carefully and I believe everywhere where it tries to query there is an implicit "where tenantId = tenantid" added so there is little risk. I'd like to have the PK still but I can't figure out how to have it set with having to provide a shim implementation of UserManager which I may end up doing in the future.
This fix was just released, hope this lets you move on to 9.x
This issue has been labeled inactive because it has been open 180 days with no activity. Please consider closing this issue if no further action is needed.
In v10 this should be resolved via the EnforceMultiTenantTracking method that can be called in the db context constructor.