MyIdentity 4.3.0
dotnet add package MyIdentity --version 4.3.0
NuGet\Install-Package MyIdentity -Version 4.3.0
<PackageReference Include="MyIdentity" Version="4.3.0" />
<PackageVersion Include="MyIdentity" Version="4.3.0" />
<PackageReference Include="MyIdentity" />
paket add MyIdentity --version 4.3.0
#r "nuget: MyIdentity, 4.3.0"
#:package MyIdentity@4.3.0
#addin nuget:?package=MyIdentity&version=4.3.0
#tool nuget:?package=MyIdentity&version=4.3.0
MyIdentity
MyIdentity is a reusable .NET 10 package that wraps ASP.NET Core Identity and adds production-ready authentication, multi-tenant SaaS support, decoupled e-mail providers, role management, and Google OAuth — all configured in a single setup call.
Stable. The current package is
4.3.0, on the 4.x line whose API baseline was set by4.0.0: a breaking change to the shipped public surface costs a major version, so4.3.0is opt-in and additive down to a single new executor —IMyIdentityCapabilityExecutor.RunAsCapabilityAsync(grants, capabilityId, work)opens an ambient scope in which the permission decision point answers from aMyIdentityCapabilityGrantSetyou build server-side from a token row you just validated, instead of from the caller's stored role assignments. It is the supported answer to "share one record with a client who has no account and never will" — the link you email for a single document. It mints no principal: no synthetic identity, no claim, no ticket, no cookie, no row, soIMyIdentityCurrentUserAccessoris untouched and every administrative service in the library still sees whatever the real accessor says — for a link bearer, anonymous — and still denies. And it is bounded by the grants it carries — its branch is evaluated before theSystemAdmin/TenantAdminshort-circuits and never reads the principal, so it never reaches them and can never be wider than the set you built; even a real administrator inside the scope is confined to it. That makes it an independent authority rather than a filter over the caller's own rights — whoever is inside gets exactly what the grants say, which is why you give them a concreteScopeId— and the inverse ofRunAsSystemAsync, which elevates to unrestricted: a leaked capability scope confines rather than escalates, and the two are mutually exclusive. Three limits to know before you adopt it — which gates honour a scope follows one rule, that a gate reading the current-user accessor denies a link bearer while a gate asking the decision point answers from the grants (so an anonymous bearer never satisfies a[MyIdentityRequirePermission], whose handler refuses it before asking, while<MyIdentityAuthorizeView>asks the decision point directly and does render from the grants), in interactive Blazor the scope is opened per handler rather than per page, and a grant only bounds a row if your domain has a scope axis to compare it against — and all three are spelled out in §38, along with the one thing that switches the feature off entirely: a decision point you registered yourself cannot read the grants, so a scope opened around it is inert. Nothing else moves for an application that does not call the new executor: the decision point, the revalidator, the current-user accessor, the authentication service, the entity model and the DI wiring all behave exactly as4.2.0left them.dotnet add package MyIdentitypicks it up with no--prereleaseflag. Upgrading from4.2.0? See §38 of the Upgrade Guide; from an alpha, §35 first. Coming from v3.x? Start at its §1. Stable is a promise about the shape of the API, not a claim of feature-completeness — the scope limits are stated in Technical Overview §11.
Try the reference app in 3 steps
The repository ships a full Blazor Server reference app (BlazorExample) that exercises the paths a consumer actually wires — cookie & Google login, registration, e-mail confirmation, multi-tenant administration, fine-grained permissions (with a custom-module demo page), and a JWT + permission-protected API. It is not exhaustive, and the coverage claim deliberately lives in exactly one place rather than in this sentence: BlazorExample/DEMO-COVERAGE.md maps public members to their call sites and states its own measured fraction, along with the two ways that measurement is known to be wrong.
Point it at a database. Edit
BlazorExample/appsettings.json→ConnectionStrings:DefaultConnection. For a zero-install local run on Windows, LocalDB works out of the box:"DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=MyIdentityDemo;Trusted_Connection=True;MultipleActiveResultSets=true"Pending migrations are applied automatically on first start.
(Optional) Use your own Google / JWT secrets. The app ships with a test JWT key and a demo Google client so it boots as-is — cookie login works immediately. To issue your own JWTs or enable Google login for your domain, provide your secrets via User Secrets (never commit them):
dotnet user-secrets set "MyIdentity:Jwt:SecretKey" "<at-least-32-byte-secret>" --project BlazorExampleRun it.
dotnet run --project BlazorExampleOpen the HTTPS URL shown in the console. On first run — while no
SystemAdminexists yet — the app opens the/setupwizard: create the firstSystemAdmin(full name, email, password), then sign in with those credentials.
ℹ️ Language / localization. The reference UI ships in Spanish, while the library, its public API, and all documentation are in English. The UI is fully localizable: the library never surfaces hard-coded display text. MyIdentity's own business-rule/e-mail messages come from
MyIdentityMessageDescriber(MyIdentity.Localization) — apublic virtual-method-per-message class the library registers in English by default; the example provides a full Spanish override (BlazorExample/Extensions/SpanishMyIdentityMessageDescriber.cs), registered viaAddMyIdentityMessageDescriber<T>()right afterAddMyIdentity(...). Because the base class gains messages in most releases and a subclass inherits the English default for each new one without the compiler saying anything,MyIdentityMessageDescriberAudit.FindUntranslated(typeof(YourDescriber))reports what you have not overridden — bind it with a one-line assertion in your own test suite (seeGETTING_STARTED.md§9).ErrorCodestays available onMyIdentityCustomExceptionfor branching logic, not for producing text (seeGETTING_STARTED.md§9). ASP.NET Core Identity's own built-in validation messages (password policy, duplicate e-mail, invalid token, …) are localized the same way, viaIdentityErrorDescriberandAddMyIdentity(..., configureIdentityBuilder: ib => ib.AddErrorDescriber<YourDescriber>())— the package ships none, so it defaults to English (the example providesSpanishIdentityErrorDescriber). To localize the rest of the UI, translate the.razorliterals and set your culture inapp.UseRequestLocalization(...)(the example useses-AR); the short login-redirect codes (?error=...) theAuthControllerproduces are separately mapped to text byBlazorExampleErrorMapper.MapLoginError.
Features
- Layered, batteries-included-but-replaceable integration. The typical path is a base
DbContext(MyIdentityDbContext<TUser,TTenant>) + oneAddMyIdentitycall +UseMyIdentity()— about 25 lines total. Every convenience is also decomposable: the schema invariants are a standalone extension method (ApplyMyIdentityModel), the pipeline is individually-callable middleware, and configuration is available as a typedMyIdentityOptionsobject, bound from appsettings, code-first, or both. Nothing is a gate — see Getting Started → Integration for the full picture and the three integration personas (typical / own hierarchy / full control). - Cookie-based login and registration with ASP.NET Core Identity.
- JWT bearer token issuance and validation.
- Google OAuth login and registration.
- E-mail confirmation and password-reset flows.
- Decoupled, abstract e-mail provider interface (
IMyIdentityEmailService) with a default console logging fallback. - Multi-tenant Tenant model.
- Two-tier authorization: built-in native roles
SystemAdmin,TenantAdmin,User(always enforced, coarse-grained) plus fine-grained permissions (opt-in) on top of them — see Authorization model. - Per-tenant custom roles (
MyIdentityRole) with delegated administration. - Fine-grained permissions engine (opt-in via
FineGrainedPermissions:Enabled) with scope-level assignments, per-moduleSupportedPermissions, fixedModify/Execute⇒Readimplications, a replaceable PDP, and[MyIdentityRequirePermission]page/route-level validation attributes. IMyIdentitySystemExecutor.RunAsSystemAsync— a first-class system/service-account principal for hosted services and background jobs that need permission-gated access without a logged-in user.- Secure defaults: anti-enumeration, account lockout,
HttpOnly+Securecookies, and strict JWT validation against user security stamps. - Early configuration validation with console reporting at application startup.
- Blazor Server ready with full reference application (
BlazorExample). - SystemAdmin impersonation (
ImpersonateAsync/StopImpersonationAsync) — sign in as another user for troubleshooting/support and return to the original session via a guarded restore, without a second login.
Security notes
- Secrets are consumer-supplied. The JWT signing key (
MyIdentity:Jwt:SecretKey) and GoogleClientId/ClientSecretare never shipped in the package. Startup fails fast if a feature is enabled with a missing, placeholder, or too-short (< 32bytes) secret. Keep secrets in User Secrets (dev) or environment variables / a key vault (prod). - No seeded admin; first-run setup wizard instead.
MyIdentitySeedDataAsyncno longer takes admin credentials at all — it seeds system roles, the fine-grained permission module catalog, and the initial tenant, but creates no admin user. While noSystemAdminexists, the app gates on a first-run/setupwizard (full name, email, password) that creates the firstSystemAdmin, holding bothSystemAdmin(cross-tenant) andTenantAdminof the seeded default tenant. This gate applies only until that first admin is created — it does not re-trigger afterwards. - Session revocation. Password reset, forced sign-out, role change, account disable, and tenant deactivation rotate the user's security stamp, revoking both the auth cookie and any issued JWTs. Caveat — do not overwrite the bearer hook. The JWT half of that guarantee is a single
JwtBearerEvents.OnTokenValidateddelegate thatAddMyIdentityinstalls from aPostConfigure<JwtBearerOptions>. Two things silently void it: assigningoptions.Events(oroptions.Events.OnTokenValidated) from your ownPostConfigure<JwtBearerOptions>registered afterAddMyIdentity, and settingoptions.EventsType— withEventsTypeset the handler resolves its events from DI and ignoresoptions.Eventsaltogether. In either case revoked JWTs keep working until they expire, with no error and no revalidator behind them. Chain, never replace: capture the existingOnTokenValidatedand await it from yours. - Anti-enumeration on login.
LoginAsyncreturns aMyIdentityLoginResultwhoseStatus(MyIdentityLoginStatus) distinguishes failure reasons —InvalidCredentials,LockedOut,RequiresEmailConfirmation,RequiresTwoFactor,NotAllowed,TenantValidationFailed,RequiresPasswordChange— for server-side logging/diagnostics. A disabled or deleted account maps toNotAllowed; a locked-out account maps toLockedOut. The consumer MUST collapse every non-success status other thanRequiresPasswordChangeto a single generic user-facing message (e.g. "invalid credentials"). Never render UI text that varies perStatus; doing so lets an attacker enumerate which e-mails/tenants exist. The reference app (AuthController/Login.razor) does this. - Data Protection at rest, fail-closed. The package persists the key ring to disk (
DataProtectionKeyPath) and always protects it: with the configured certificate (ProtectKeysWithCertificate, viaMyIdentity:Authentication:DataProtection:CertificateThumbprint) when one is set, otherwise with Windows DPAPI (ProtectKeysWithDpapi) when running on Windows. On non-Windows hosts — or any multi-instance deployment sharing a key ring — DPAPI is unavailable; if no certificate thumbprint is configured in that case,AddMyIdentitythrows at startup and refuses to run rather than persist the key ring in plaintext. Configure a certificate thumbprint for production on non-Windows/multi-instance deployments; to protect keys with Azure Key Vault/AWS KMS/etc. instead, setMyIdentity:Authentication:DataProtection:KeyProtectiontoNoneand chain your own provider (e.g.services.AddDataProtection().ProtectKeysWithAzureKeyVault(...)). SettingNoneis required, not optional, when you chain afterAddMyIdentity: the library contributes its encryptor from anIConfigureOptions<KeyManagementOptions>registered insideAddMyIdentity, and options configuration runs in registration order — so a provider chained afterwards has not yet setXmlEncryptorby the time the library's setup runs, and on non-Windows with no thumbprint the fail-closed check aborts host start before your provider is ever reached.Nonemakes the library set no encryptor of its own (it logs a warning saying so, since the key ring is plaintext if you then chain nothing). Chaining beforeAddMyIdentityalso works withoutNone, because the library defers to anXmlEncryptorthat is already set.
Documentation
- Technical Overview — for evaluating the library before adopting it: what it does and how it is built, the architecture and domain model, the authentication flows, the two-tier authorization model and the PDP, exactly where the tenant-isolation boundary is (and its caveats), the security guarantees and how each is enforced, the test/audit history, the extensibility seams, and the honest scope limitations.
- Getting Started — a 5-minute Quickstart, then the full layered integration guide (typical path, keeping your own
DbContexthierarchy, fully manual control, and the three integration personas), domain models, email service, the two-tier authorization model, fine-grained permission gating and module declaration, configuration validation, handlingMyIdentityCustomExceptionin your UI, and running background jobs as the system account (RunAsSystemAsync). - Upgrade Guide — v3.x → v4.0 breaking changes and step-by-step migration (§1–§6, which is all a v3.x consumer has to walk), plus the changelog for every release since as reference material.
Requirements
| Requirement | Details |
|---|---|
| .NET SDK | 10.0+ |
| Database | SQL Server or EF Core supported database providers |
| App type | ASP.NET Core 10 / Blazor Server 10 |
Quick Start (5 minutes)
The minimal happy path: a connection string and an app name get you a running app with cookie authentication
end to end — no manual schema wiring, no hand-rolled middleware pipeline, no credentials to configure up
front (the first SystemAdmin is created on first run through the /setup wizard). See
Getting Started → Integration for the full layered guide
(the decomposed form for keeping your own DbContext hierarchy, fully manual control, and which of the
three integration personas fits your app).
1. Install
dotnet add package MyIdentity
2. Domain models + DbContext
Extend the base user/tenant classes, then derive MyIdentityDbContext<TUser,TTenant> — it owns the RBAC
DbSets, the table maps, and the schema-integrity invariants (the filtered unique index, the UserId FK).
Tenant isolation is not a DbContext concern: there is no automatic per-tenant query filter — the
authoritative tenant boundary is the MyIdentity service layer, which scopes every query by tenant explicitly
(see Getting Started §3.2):
using MyIdentity.Data;
using MyIdentity.Models;
public class MyAppUser : MyIdentityUser { public string? Department { get; set; } }
public class MyAppTenant : MyIdentityTenant { public string? TaxId { get; set; } }
public class MyAppContext : MyIdentityDbContext<MyAppUser, MyAppTenant>
{
public MyAppContext(DbContextOptions<MyAppContext> options) : base(options) { }
}
3. Configure appsettings.json
Configure the general parameters of the identity engine:
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=MyAppDb;Trusted_Connection=True;TrustServerCertificate=True"
},
"MyIdentity": {
"General": {
"AppName": "MyApp",
"UseTenantOnLogin": true
},
"FineGrainedPermissions": {
"Enabled": true,
"Modules": [
{ "Code": "Billing" },
{ "Code": "Inventory", "SupportedPermissions": "Read,Modify" }
]
},
"Authentication": {
"Cookies": {
"CookieExpireTimeSpanDays": 14,
"SlidingExpiration": true
},
"DataProtection": {
"EnableDataProtection": true,
"TokenLifespanHours": 3,
"DataProtectionKeyPath": "App_Data/DataProtection-Keys"
},
"Paths": {
"LoginPath": "/Account/Login",
"LogoutPath": "/Account/Logout",
"AccessDeniedPath": "/Account/AccessDenied"
},
"Google": {
"ClientId": "<your-google-client-id>",
"ClientSecret": "<your-google-client-secret>"
}
},
"IdentitySettings": {
"RequireUniqueEmail": true,
"RequireConfirmedEmail": true,
"Password": {
"RequireDigit": true,
"RequiredLength": 8,
"RequireLowercase": true,
"RequireUppercase": true,
"RequireNonAlphanumeric": false
},
"Lockout": {
"DefaultLockoutTimeSpan": "00:30:00",
"MaxFailedAccessAttempts": 5
}
},
"Jwt": {
"Issuer": "https://myapp.example.com",
"Audience": "myapp-clients",
"SecretKey": "<at-least-32-bytes-secret>",
"ExpirationHours": 1
}
}
}
In
appsettings.json, everyFineGrainedPermissions:Modulesentry must be an object with aCode— a bare string is not bound by the configuration binder and is silently dropped. OmitSupportedPermissionsto support all flags, or declare{ "Code", "SupportedPermissions" }to restrict a module to a subset (e.g. noExecute) — enforced when saving a role's permission matrix. In C# code, declare each module explicitly withAddModule(code, perms)(ornew MyIdentityModuleDefinition(code, perms)). The old bare-string formoptions.Modules.Add("Tasks")has been removed (G21): a bare string no longer compiles where a module is expected — but note that what G21 removed is a syntax, not a permissive default.AddModule("Tasks")and{ "Code": "Tasks" }still default to all flags, so the save-time restriction only bites for modules you deliberately narrow, and narrowing is irreversible. See Getting Started §4 for the full shape, the code-basedAddModulehelper, and what startup validation rejects.
4. Wire it up in Program.cs
One AddMyIdentity call, your email service, and one UseMyIdentity():
using MyIdentity.Extensions;
using MyIdentity.Interfaces;
var builder = WebApplication.CreateBuilder(args);
// One call registers Identity, EF Core, JWT, Google, and Data Protection.
// All values — Identity password/lockout policy AND fine-grained permissions (Enabled + Modules) — are read
// from appsettings (MyIdentity:IdentitySettings and MyIdentity:FineGrainedPermissions). No need to hard-code them here.
// AddMyIdentity<TUser,TTenant,TContext> returns a MyIdentityBuilder<TUser,TTenant,TContext> that captures the
// <TUser,TTenant,TContext> triple ONCE — hold onto it (a local variable survives builder.Build()) so the
// app-phase calls below don't need to repeat it. `myIdentity.Services` is the same IServiceCollection
// AddMyIdentity registered into, so you can keep chaining further registrations off it if you want to.
var myIdentity = builder.Services.AddMyIdentity<MyAppUser, MyAppTenant, MyAppContext>(
builder.Configuration,
builder.Environment,
// OPTIONAL: plug in your own IdentityErrorDescriber to localize Identity's built-in error messages.
// The package is English-first and ships no language-specific describer.
configureIdentityBuilder: identityBuilder =>
identityBuilder.AddErrorDescriber<SpanishIdentityErrorDescriber>());
// The optional callbacks are still available for advanced/programmatic overrides (they run AFTER appsettings):
// configureIdentity: opts => { ... } // override IdentityOptions
// configurePermissions: opts => { ... } // override fine-grained permissions Enabled/Modules
// configureOptions: opts => { ... } // configure MyIdentityOptions entirely in code, or override bound values
// Implement and register your transactional email sender
myIdentity.Services.AddScoped<IMyIdentityEmailService, MyAppEmailService>();
// Both opt-in, standalone service registrations — only add the ones your hosting model needs.
myIdentity.Services.AddMyIdentityAntiforgeryControllers(); // AddControllersWithViews(), for [ValidateAntiForgeryToken]
myIdentity.Services.AddMyIdentityAuthRateLimiter(); // the "auth" rate-limiter policy
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();
var app = builder.Build();
// Migrate FIRST: MyIdentitySeedDataAsync no longer migrates the schema itself (UPGRADE.md §13, G25) — seeding
// against an unmigrated schema fails. myIdentity.MigrateAsync/.SeedAsync delegate to the standalone
// MyIdentityMigrateAsync<...>()/MyIdentitySeedDataAsync<...>() extension methods below — both forms remain
// fully supported; the builder form just drops the repeated <TUser,TTenant,TContext> triple.
await myIdentity.MigrateAsync(app);
// Seed roles, modules catalog, and the initial System tenant (the first SystemAdmin is created at
// runtime via the /setup wizard).
string[] appRoles = ["Administrator", "Supervisor"];
await myIdentity.SeedAsync(app, appRoles);
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseMyIdentity(o => o.WithAuthRateLimiter()); // access-denied redirect + rate limiter + auth + authz + antiforgery
app.MapControllers();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
See Getting Started → Integration §3.3
for the decomposed form (individual middleware, ApplyMyIdentityModel for your own DbContext hierarchy,
the virtual seams) if you need to change one piece of this without dropping the rest.
Auth API
Cookie-based Sign-in
var result = await _authService.LoginAsync(loginDto);
if (result.Status == MyIdentityLoginStatus.RequiresPasswordChange)
{
// Redirect unauthenticated user to change password page
return Redirect($"/Account/ChangePassword?userId={result.User.Id}");
}
if (!result.Succeeded)
{
return Redirect($"/Account/Login?error={result.Status}");
}
return Redirect("/");
Multi-tenant login.
SystemAdminusers bypass the tenant check entirely — membership is resolved throughUserManager.IsInRoleAsync, i.e. by normalized name, so only the globalSystemAdminrole earns the exemption (a tenant-owned role merely named "SystemAdmin" normalizes to{tenantId}:SYSTEMADMINand does not). For everyone else the check runs in order and can reject at four points, all collapsed into a single genericTenantValidationFailed. Two of them are not governed byMyIdentity:General:UseTenantOnLogin:
- Always enforced, whatever the flag is set to: (1) if the user has a
TenantId, it must resolve to an existing tenant row, and (2) that tenant must be active (IsActive == true). These mirror exactly what the Blazor revalidator enforces on every already-issued session, so the library never mints a principal its own revalidator would reject. Both are logged as warnings at the default log level. A user whoseTenantIdisnullpasses both.- Added only when
UseTenantOnLoginistrue: (3) the user must have a tenant assigned at all, and (4) a suppliedTradingName(fantasy name) must match that tenant's — case-insensitive and trimmed ("empresa1"matches"Empresa1"). An empty/omittedTradingNameskips only the name comparison (4), never (3). These two are logged only underMyIdentity:General:DebugLogEnabled(defaultfalse) — enable it and consult your server-side logs, not the UI, when diagnosing them.So leaving
UseTenantOnLoginoff does not keep a non-SystemAdminmember of a deactivated tenant able to sign in: deactivating a tenant is a real login gate, not just a one-shot session revocation. What the flag adds is the requirement to have a tenant — turning it on additionally locks out every tenant-less non-SystemAdminaccount (a nativeTenantAdminwith no tenant included), even when no name is sent at all. The reference app surfacesTenantValidationFailedas an "invalid credentials" message (anti-enumeration). This behavior is driven only by server-side configuration;MyIdentityLoginDTOhas no client-settable flag for it — there is nothing on the DTO for a caller to toggle.
JWT Token Issuance
MyIdentityJwtTokenResult tokenResult = await _authService.IssueJwtAsync(loginDto);
// returns tokenResult.AccessToken, tokenResult.ExpiresAtUtc, tokenResult.TokenType
Pin
AuthenticationSchemeson bearer-only endpoints.AddMyIdentityregisters cookie auth as the default scheme. If your app also exposes JWT-bearer API controllers (e.g. alongside Blazor pages), a bare[Authorize]on those controllers authenticates against the default (cookie) scheme, not the bearer one — a request carrying only a validAuthorization: Bearer <token>header, with no auth cookie, is silently treated as anonymous (redirected, not rejected with401), and the JWT bearer handler never even runs. Pin the scheme explicitly:[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]. The reference app'sRestAPISampleControllerdoes this; seeBlazorExample/DEMO-COVERAGE.mdfor the empirical repro that uncovered it.
Once pinned,
[MyIdentityRequirePermission]authorizes the bearer principal — even in a Blazor Server host. MyIdentity resolves the current user per request context: a bearer/API request (which has a request-boundHttpContext) is authorized againstHttpContext.User, not the Blazor circuit'sAuthenticationStateProviderstate. SeeUPGRADE.md§20 (RUN-1) for the bug this fixed.
Google OAuth Login
bool ok = await _authService.ProcessGoogleResponseAsync(authenticateResult, baseUrl);
When starting the challenge, pack the target tenant name into
AuthenticationProperties.Items[MyIdentityAuthProperties.TenantName]—ProcessGoogleResponseAsyncreads that exact key back on the callback.MyIdentityAuthProperties.TenantName(inMyIdentity.Extensions) is a public constant whose value is still"CompanyName"— leftover v3 (pre-Tenant-rename) internal wiring, never serialized or exposed through any public DTO, so it is purely cosmetic — but use the constant rather than the bare string so a future rename of the value doesn't silently break your code.
Email Confirmation
await _authService.ConfirmEmailAsync(emailConfirmationDto);
User Service Operations
await _userService.CreateAsync(user, password);
await _userService.UpdateAsync(user); // note: does NOT change the tenant FK (anti mass-assignment) — use the dedicated methods below
// Tenant membership — explicit, authorized operations (UpdateAsync intentionally ignores the tenant FK).
await _userService.AssignToTenantAsync(userId, tenantId); // assign/move a user to a tenant — SystemAdmin only; rotates the security stamp
await _userService.RemoveFromTenantAsync(userId); // detach from the tenant, strip the tenant-scoped role, revoke sessions
var tenantAdminId = await _userService.GetTenantAdminIdAsync(tenantId); // id of the tenant's TenantAdmin (by real role membership), or null
// Native system-role membership — SystemAdmin-only, rotates the security stamp.
await _userService.SetTenantAdminAsync(userId); // designates the tenant's TenantAdmin; demotes any previous one in the same tenant
await _userService.RemoveTenantAdminAsync(userId); // idempotent
// Deletion — two explicit strategies; the caller decides which to use and how to handle related records.
await _userService.SoftDeleteAsync(userId); // marks Deleted/disabled, strips roles + RBAC, rotates the stamp
await _userService.HardDeleteAsync(userId); // permanent; throws if related records (foreign keys) block removal
await _userService.SetEnabledAsync(userId, false); // activate/deactivate; disabling revokes live sessions/JWTs
await _userService.ConfirmEmailManuallyAsync(userId); // administratively confirm an e-mail
await _userService.ToggleLockAsync(userId);
await _userService.ForceSignOutAsync(userId); // revoke all active cookies and JWTs immediately
await _userService.ChangePasswordAsync(userId, oldPassword, newPassword);
Fine-Grained Permissions Engine & Gating
Fine-grained permissions are opt-in (MyIdentity:FineGrainedPermissions:Enabled=true) — the second, granular
tier on top of the always-on native roles (see Authorization model
for the full two-tier picture and which gate to use). When enabled, annotate routes, controllers, or Blazor
pages to restrict access to specific modules and permissions:
using MyIdentity.Authorization;
using MyIdentity.Models;
[MyIdentityRequirePermission("Inventory", MyIdentityPermission.Read)]
public class InventoryController : ControllerBase
{
// Restricts access to users holding Read permission on Inventory module
}
In Blazor Components, use IMyIdentityPermissionService for manual programmatic check:
@inject IMyIdentityPermissionService PermissionService
@code {
private async Task SaveAsync()
{
if (await PermissionService.HasPermissionAsync("Inventory", MyIdentityPermission.Modify, scopeId: activeBranchId))
{
// Perform action
}
}
}
Use MyIdentityPolicies.Permission(module, permission) to check permissions in Razor templates:
<AuthorizeView Policy="@MyIdentityPolicies.Permission("Inventory", MyIdentityPermission.Modify)">
<MudButton>Modify Item</MudButton>
</AuthorizeView>
Fixed implications, replaceable PDP. The shipped evaluator applies one fixed rule: a permission stored as Modify or Execute also grants Read (never the reverse). For a plain user, scopeId is an opaque sub-scope inside the caller's tenant — null means tenant-wide, a value means "only that scope, plus tenant-wide grants." A TenantAdmin is unbounded for any scopeId within their own tenant (and SystemAdmin is unbounded across every tenant); scopeId is consumer-defined and not interpreted by the library — validate what a given value means in your own app. To use a different evaluation strategy, register your own IMyIdentityPermissionService before calling AddMyIdentity: the package registers its default with TryAddScoped, so a prior registration wins.
Per-tenant roles and delegated administration
Beyond the fixed SystemAdmin / TenantAdmin / User roles, a tenant can define its own custom roles (e.g. "Cajero"). The role entity is MyIdentityRole : IdentityRole, adding int? TenantId (null = system/global role; a value = owned by that tenant) and string DisplayName (the friendly name). Two tenants may reuse the same DisplayName — ASP.NET Identity's NormalizedName unique index is global, so the library namespaces tenant-role names internally as "{TenantId}:{NORMALIZED}" via a registered MyIdentityRoleManager; consumers never see or construct that composite, they work with DisplayName + TenantId.
Custom roles are managed through the tenant-aware IMyIdentityRoleService — not RoleManager<MyIdentityRole>.FindByNameAsync:
IEnumerable<MyIdentityRole> roles = await roleService.GetTenantRolesAsync(tenantId);
IdentityResult created = await roleService.CreateAsync("Cajero", tenantId);
MyIdentityRole? cajero = await roleService.FindTenantRoleAsync(tenantId, "Cajero");
await roleService.UpdateAsync(cajero.Id, "Cajero de Turno");
await roleService.DeleteAsync(cajero.Id);
Administration is delegated: a SystemAdmin manages any tenant's roles; a TenantAdmin only its own. MyIdentityUserRoleAssignment (ScopeId = your own sub-scope, null for tenant-wide) is exclusive to these custom roles — it rejects assigning a system role or a role owned by a different tenant. System-role membership (SystemAdmin, TenantAdmin) stays native on AspNetUserRoles, granted only by seeding or SetTenantAdminAsync/RemoveTenantAdminAsync (see User Service Operations); SystemAdmin itself is never assignable through an API.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Authentication.Google (>= 10.0.11)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.11)
- Microsoft.AspNetCore.Identity.EntityFrameworkCore (>= 10.0.11)
- Microsoft.EntityFrameworkCore.SqlServer (>= 10.0.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.3.0 | 0 | 9/14/2026 |
| 4.2.0 | 101 | 8/30/2026 |
| 4.1.0 | 96 | 8/30/2026 |
| 4.0.0 | 114 | 8/23/2026 |
| 4.0.0-alpha.21 | 73 | 8/11/2026 |
| 4.0.0-alpha.19 | 77 | 8/7/2026 |
| 4.0.0-alpha.18 | 72 | 8/7/2026 |
| 4.0.0-alpha.17 | 75 | 8/5/2026 |
| 4.0.0-alpha.15 | 70 | 8/3/2026 |
| 4.0.0-alpha.14 | 66 | 8/2/2026 |
| 4.0.0-alpha.13 | 71 | 8/2/2026 |
| 4.0.0-alpha.11 | 60 | 7/31/2026 |
| 4.0.0-alpha.10 | 62 | 7/31/2026 |
| 4.0.0-alpha.9 | 63 | 7/31/2026 |
| 4.0.0-alpha.8 | 70 | 7/29/2026 |
| 4.0.0-alpha.7 | 66 | 7/24/2026 |
| 4.0.0-alpha.6 | 64 | 7/15/2026 |
| 4.0.0-alpha.5 | 69 | 7/13/2026 |
| 4.0.0-alpha.3 | 70 | 7/11/2026 |
| 4.0.0-alpha.1 | 70 | 7/10/2026 |
v4.3.0: opt-in and additive. Nothing changes for an application that does not call the new executor -- the permission decision point, the revalidator, the current-user accessor, the authentication service, the entity model and the DI wiring all behave exactly as 4.2.0 left them, and the only new registration AddMyIdentity makes is the executor itself. IMyIdentityCapabilityExecutor.RunAsCapabilityAsync(grants, capabilityId, work) opens an ambient scope in which the permission decision point answers from a MyIdentityCapabilityGrantSet you built server-side, instead of from the caller's stored MyIdentityUserRoleAssignment rows. It is the supported answer to "share one record with a client who has no account and never will" -- the link you email for a single document. It mints NO principal: no synthetic identity, no claim, no ticket, no cookie, no row, and no new entity. IMyIdentityCurrentUserAccessor is untouched, so inside a capability scope every administrative service in this library still sees whatever the real accessor says -- for a link bearer, anonymous -- and still denies. That is the whole design rather than an implementation detail: caller gates in MyIdentityTenantService and MyIdentityUserService infer tenant membership from "authenticated plus a tenant claim" WITHOUT consulting the decision point, so a synthetic principal carrying a tenant claim would have been able to edit its own tenant row and read that tenant's entire user roster. MyIdentityCapabilityIsolationTests pins that a capability never becomes an identity. A capability is BOUNDED BY the grants it carries, and never reaches the role short-circuits. Its branch is the FIRST check in MyIdentityPermissionService's shared short-circuit preamble, above the SystemAdmin and TenantAdmin short-circuits, and it never reads the principal, so it never reaches them and can answer from nothing but the grant set you handed in: a real administrator running inside a capability scope is confined to that set for as long as it is open. Read that as bounded BY the grants rather than as a restriction on the caller -- a capability is an INDEPENDENT authority that replaces the stored-assignment path instead of intersecting with it, so a caller holding no assignments at all gets exactly what the grants say, which is why a null ScopeId grant is tenant-wide for whoever is inside (see limit 3). What it can never do is reach the unrestricted answer a SystemAdmin or TenantAdmin gets, and that is the property separating it from RunAsSystemAsync, which elevates to the service account: a leaked capability scope confines whoever picks it up to those grants, it does not escalate them. The two are mutually exclusive, and both executors refuse the pairing in either order. The grants never reach the client: the grant set is built server-side from a token row you just validated, is copied on construction, and is not readable back out of the scope, so there is nothing to sign and nothing to tamper with. The arithmetic over those grants mirrors the stored path -- the same fixed implications, the same tenant-wide floor over every scope, the same consistency between HasPermissionAsync and GetAccessibleScopeIdsAsync -- with TWO deliberate divergences, both fail-closed. (a) Module codes are compared with StringComparison.Ordinal, NOT under the database collation the stored path compares under, so a grant whose module code differs from the seeded one only in casing silently never matches: every question about that module answers no, with no exception and nothing logged. If a share link denies a bearer you are certain you granted, compare the module code you passed to MyIdentityCapabilityGrant against the seeded MyIdentityModule.Code character for character before looking anywhere else. (b) A required MyIdentityPermission.None is DENIED here, where the stored path answers it as the tautology it is -- the stored path is only reached past an authentication short-circuit and this one has none above it. Outside those two, a capability grant means what the identically shaped stored assignment would have meant. Revocation is per operation: there is no session to revalidate, so validate the token every time you open a scope and a revoked capability stops working on the bearer's next interaction -- stricter than a normal session, which tolerates up to RevalidationIntervalMinutes of staleness. If you replaced IMyIdentityPermissionService with your own, capability scopes do NOTHING: the library registers its own decision point with TryAdd, so yours is the one that stays, and yours cannot read a scope's grants because they are internal to the assembly -- every read inside a scope is still answered from the caller's stored assignments, which for an account-less bearer means denied, with no exception and nothing logged. That is tracked as CAP-3 on the roadmap. Three limits to know before you adopt it. (1) Which gates honour a capability scope follows one rule: a gate that reads the current-user accessor denies a link bearer, and a gate that asks the decision point is answered from the grants. So an ANONYMOUS bearer never satisfies a [MyIdentityRequirePermission] -- the handler refuses an unauthenticated principal before it ever asks -- which is deliberate and keeps the capability path out of the framework's authorization pipeline; but MyIdentityAuthorizeView has no authentication precondition and calls the decision point directly, so inside a scope it renders from the grants, as does the attribute for an authenticated caller who happens to be inside one. Authorize the account-less path by calling IMyIdentityPermissionService from your own facade. (2) In interactive Blazor the scope is opened per HANDLER, not per page, because the ambient marker does not survive across renders: route the capability path through a single facade, and know that forgetting to open the scope makes the call run anonymous, which denies. (3) A capability grant only contains anything if your domain compares the grant's ScopeId against the scope of the row, and only when you evaluate it against the STRONG question (HasPermissionAsync with a concrete scope id); in a domain with no scope axis a (Module, Read, 123) grant satisfies the weak question (HasPermissionInAnyContextAsync) without bounding any row, and fails the strong question asked with a null scope, because a bounded grant never satisfies a tenant-wide request. Prefer a concrete ScopeId: null means tenant-wide, which is the opposite of what a share-one-record link wants. Read UPGRADE.md section 38. Consumer action: none, unless you want the feature -- dotnet add package MyIdentity picks 4.3.0 up with no code change. Everything else is as 4.2.0 left it: dependencies are unchanged at 10.0.11 (Microsoft.AspNetCore.Authentication.Google, Microsoft.AspNetCore.Authentication.JwtBearer, Microsoft.AspNetCore.Identity.EntityFrameworkCore, Microsoft.EntityFrameworkCore.SqlServer); the ASP.NET Core shared framework is consumed through a frameworkReference, so Identity core, Blazor Components(.Authorization), Data Protection and the configuration binders are not package dependencies. Consumers coming from 3.x: the v3.x -> v4.0 migration is UNCHANGED and still lives in UPGRADE.md sections 1-4 (breaking-change overview, EF Core schema migration, the per-tenant role model, and the code changes to make), with sections 5-6 as the pre-production checklist. Sections 7-38 are the per-release changelog and are historical: you do not walk them one by one, you migrate from 3.x to this release once. The scope limits are unchanged and are the same ones TECHNICAL_OVERVIEW.md section 11 states: two-factor authentication is not shipped (the login status carries a forward-compatible RequiresTwoFactor signal, but there is no enrollment or verification surface); tenant isolation is enforced by the SERVICE LAYER and not by a global query filter, so ad-hoc queries you write outside the services are yours to scope; SaveRolePermissionsAsync and UpsertRolePermissionAsync are last-writer-wins on the same cell, with no typed conflict detection; and security events go to ILogger only, with no persistence seam for non-repudiation. Each of those is a deliberate boundary, documented where it bites, not an omission discovered late.