|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 98 KiB |
@ -0,0 +1,151 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Learn how Shared User Accounts work in ABP (UserSharingStrategy): login flow with tenant selection, switching tenants, inviting users, and the Pending Tenant registration flow." |
|||
} |
|||
``` |
|||
|
|||
# Shared User Accounts |
|||
|
|||
This document explains **Shared User Accounts**: a single user account can belong to multiple tenants, and the user can choose/switch the active tenant when signing in. |
|||
|
|||
> This is a **commercial** feature. It is mainly provided by Account.Pro and Identity.Pro (and related SaaS UI). |
|||
|
|||
## Introduction |
|||
|
|||
In a typical multi-tenant setup with the **Isolated** user strategy, a user belongs to exactly one tenant (or the Host), and uniqueness rules (username/email) are usually scoped per tenant. |
|||
|
|||
If you want a `one account, multiple tenants` experience (for example, inviting the same email address into multiple tenants), you should enable the **Shared** user strategy. |
|||
|
|||
## Enabling Shared User Accounts |
|||
|
|||
Enable shared accounts by configuring `AbpMultiTenancyOptions.UserSharingStrategy`: |
|||
|
|||
```csharp |
|||
Configure<AbpMultiTenancyOptions>(options => |
|||
{ |
|||
options.IsEnabled = true; |
|||
options.UserSharingStrategy = TenantUserSharingStrategy.Shared; |
|||
}); |
|||
``` |
|||
|
|||
### Constraints and Behavior |
|||
|
|||
When you use Shared User Accounts: |
|||
|
|||
- Username/email uniqueness becomes **global** (Host + all tenants). A username/email can exist only once, but that user can be invited into multiple tenants. |
|||
- Some security/user management settings (2FA, lockout, password policies, recaptcha, etc.) are managed at the **Host** level. |
|||
|
|||
If you are migrating from an isolated strategy, ABP will validate the existing data when you switch to Shared. If there are conflicts (e.g., the same email registered as separate users in different tenants), you must resolve them before enabling the shared strategy. See the [Migration Guide](#migration-guide) section below. |
|||
|
|||
## Tenant Selection During Login |
|||
|
|||
If a user account belongs to multiple tenants, the login flow prompts the user to select the tenant to sign in to: |
|||
|
|||
 |
|||
|
|||
## Switching Tenants |
|||
|
|||
After signing in, the user can switch between the tenants they have joined using the tenant switcher in the user menu: |
|||
|
|||
 |
|||
|
|||
## Leaving a Tenant |
|||
|
|||
Users can leave a tenant. After leaving, the user is no longer a member of that tenant, and the tenant can invite the user again later. |
|||
|
|||
> When a user leaves and later re-joins the same tenant, the `UserId` does not change and tenant-related data (roles, permissions, etc.) is preserved. |
|||
|
|||
## Inviting Users to a Tenant |
|||
|
|||
Tenant administrators can invite existing or not-yet-registered users to join a tenant. The invited user receives an email; clicking the link completes the join process. If the user doesn't have an account yet, they can register and join through the same flow. |
|||
|
|||
While inviting, you can also assign roles so the user gets the relevant permissions automatically after joining. |
|||
|
|||
> The invitation feature is also available in the Isolated strategy, but invited users can join only a single tenant. |
|||
|
|||
 |
|||
|
|||
## Managing Invitations |
|||
|
|||
From the invitation modal, you can view and manage sent invitations, including resending an invitation email and revoking individual or all invitations. |
|||
|
|||
 |
|||
|
|||
## Accepting an Invitation |
|||
|
|||
If the invited person already has an account, clicking the email link shows a confirmation screen to join the tenant: |
|||
|
|||
 |
|||
|
|||
If the invited person doesn't have an account yet, clicking the email link takes them to registration and then joins them to the tenant: |
|||
|
|||
 |
|||
|
|||
After accepting the invitation, the user can sign in and switch to that tenant. |
|||
|
|||
 |
|||
|
|||
## Inviting an Admin After Tenant Creation |
|||
|
|||
With Shared User Accounts, you typically don't create an `admin` user during tenant creation. Instead, create the tenant first, then invite an existing user (or a new user) and grant the required roles. |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
> In the Isolated strategy, tenant creation commonly seeds an `admin` user automatically. With Shared User Accounts, you usually use invitations instead. |
|||
|
|||
### Registration Strategy for New Users |
|||
|
|||
When a user registers a new account, the user is not a member of any tenant by default (and is not a Host user). You can configure `AbpIdentityPendingTenantUserOptions.Strategy` to decide what happens next. |
|||
|
|||
Available strategies: |
|||
|
|||
- **CreateTenant**: Automatically creates a tenant for the new user and adds the user to that tenant. |
|||
- **Redirect**: Redirects the user to a URL where you can implement custom logic (commonly: a tenant selection/join experience). |
|||
- **Inform** (default): Shows an informational message telling the user to contact an administrator to join a tenant. |
|||
|
|||
> In this state, the user can't proceed into a tenant context until they follow the configured strategy. |
|||
|
|||
### CreateTenant Strategy |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.CreateTenant; |
|||
}); |
|||
``` |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
### Redirect Strategy |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.Redirect; |
|||
options.RedirectUrl = "/your-custom-logic-url"; |
|||
}); |
|||
``` |
|||
|
|||
### Inform Strategy |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.Inform; |
|||
}); |
|||
``` |
|||
|
|||
 |
|||
|
|||
## Migration Guide |
|||
|
|||
If you plan to migrate an existing multi-tenant application from an isolated strategy to Shared User Accounts, keep the following in mind: |
|||
|
|||
1. **Uniqueness check**: Before enabling Shared, ensure all existing usernames and emails are unique globally. ABP performs this check when you switch the strategy and reports conflicts. |
|||
2. **Tenants with separate databases**: If some tenants use separate databases, you must ensure the Host database contains matching user records in the `AbpUsers` table (and, if you use social login / passkeys, also sync `AbpUserLogins` and `AbpUserPasskeys`) so the Host-side records match the tenant-side data. After that, the framework can create/manage the user-to-tenant associations. |
|||
|
|||
@ -1,6 +1,10 @@ |
|||
namespace Volo.Abp.AspNetCore.Mvc.MultiTenancy; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.MultiTenancy; |
|||
|
|||
public class MultiTenancyInfoDto |
|||
{ |
|||
public bool IsEnabled { get; set; } |
|||
|
|||
public TenantUserSharingStrategy UserSharingStrategy { get; set; } |
|||
} |
|||
|
|||
@ -0,0 +1,270 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Data; |
|||
|
|||
public static class DataFilterExtensions |
|||
{ |
|||
private sealed class CompositeDisposable : IDisposable |
|||
{ |
|||
private readonly IDisposable[] _disposables; |
|||
private bool _disposed; |
|||
|
|||
public CompositeDisposable(IDisposable[] disposables) |
|||
{ |
|||
_disposables = disposables; |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
if (_disposed) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
_disposed = true; |
|||
|
|||
foreach (var disposable in _disposables) |
|||
{ |
|||
disposable?.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5, T6>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>(), |
|||
filter.Disable<T6>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5, T6, T7>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>(), |
|||
filter.Disable<T6>(), |
|||
filter.Disable<T7>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Disable<T1, T2, T3, T4, T5, T6, T7, T8>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
where T8 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Disable<T1>(), |
|||
filter.Disable<T2>(), |
|||
filter.Disable<T3>(), |
|||
filter.Disable<T4>(), |
|||
filter.Disable<T5>(), |
|||
filter.Disable<T6>(), |
|||
filter.Disable<T7>(), |
|||
filter.Disable<T8>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5, T6>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>(), |
|||
filter.Enable<T6>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5, T6, T7>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>(), |
|||
filter.Enable<T6>(), |
|||
filter.Enable<T7>() |
|||
}); |
|||
} |
|||
|
|||
public static IDisposable Enable<T1, T2, T3, T4, T5, T6, T7, T8>(this IDataFilter filter) |
|||
where T1 : class |
|||
where T2 : class |
|||
where T3 : class |
|||
where T4 : class |
|||
where T5 : class |
|||
where T6 : class |
|||
where T7 : class |
|||
where T8 : class |
|||
{ |
|||
return new CompositeDisposable(new[] |
|||
{ |
|||
filter.Enable<T1>(), |
|||
filter.Enable<T2>(), |
|||
filter.Enable<T3>(), |
|||
filter.Enable<T4>(), |
|||
filter.Enable<T5>(), |
|||
filter.Enable<T6>(), |
|||
filter.Enable<T7>(), |
|||
filter.Enable<T8>() |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
namespace Volo.Abp.EntityFrameworkCore.GlobalFilters; |
|||
|
|||
public interface IAbpEfCoreCompiledQueryCacheKeyProvider |
|||
{ |
|||
string? GetCompiledQueryCacheKey(); |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
using System; |
|||
using Volo.Abp.Domain.Entities.Events.Distributed; |
|||
using Volo.Abp.EventBus; |
|||
|
|||
namespace Volo.Abp.MultiTenancy; |
|||
|
|||
[Serializable] |
|||
[EventName("abp.multi_tenancy.create.tenant")] |
|||
public class CreateTenantEto : EtoBase |
|||
{ |
|||
public string Name { get; set; } = default!; |
|||
|
|||
public string AdminEmailAddress { get; set; } = default!; |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
namespace Volo.Abp.MultiTenancy; |
|||
|
|||
public enum TenantUserSharingStrategy |
|||
{ |
|||
Isolated = 0, |
|||
|
|||
Shared = 1 |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
using System; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
namespace Volo.Abp.Identity; |
|||
|
|||
[Serializable] |
|||
public class IdentityUserPasswordChangedEto : IMultiTenant |
|||
{ |
|||
public Guid Id { get; set; } |
|||
|
|||
public Guid? TenantId { get; set; } |
|||
|
|||
public string Email { get; set; } |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
using System; |
|||
using Volo.Abp.EventBus; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
namespace Volo.Abp.Users; |
|||
|
|||
[Serializable] |
|||
[EventName("Volo.Abp.Users.InviteUserToTenantRequested")] |
|||
public class InviteUserToTenantRequestedEto : IMultiTenant |
|||
{ |
|||
public Guid? TenantId { get; set; } |
|||
|
|||
public string Email { get; set; } |
|||
|
|||
public bool DirectlyAddToTenant { get; set; } |
|||
} |
|||