MyIdentity 4.3.0

dotnet add package MyIdentity --version 4.3.0
                    
NuGet\Install-Package MyIdentity -Version 4.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MyIdentity" Version="4.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MyIdentity" Version="4.3.0" />
                    
Directory.Packages.props
<PackageReference Include="MyIdentity" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MyIdentity --version 4.3.0
                    
#r "nuget: MyIdentity, 4.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MyIdentity@4.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MyIdentity&version=4.3.0
                    
Install as a Cake Addin
#tool nuget:?package=MyIdentity&version=4.3.0
                    
Install as a Cake Tool

MyIdentity

NuGet NuGet Downloads .NET 10

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 by 4.0.0: a breaking change to the shipped public surface costs a major version, so 4.3.0 is 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 a MyIdentityCapabilityGrantSet you 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, so IMyIdentityCurrentUserAccessor is 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 the SystemAdmin/TenantAdmin short-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 concrete ScopeId — and the inverse of RunAsSystemAsync, 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 as 4.2.0 left them. dotnet add package MyIdentity picks it up with no --prerelease flag. Upgrading from 4.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.

  1. Point it at a database. Edit BlazorExample/appsettings.jsonConnectionStrings: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.

  2. (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 BlazorExample
    
  3. Run it.

    dotnet run --project BlazorExample
    

    Open the HTTPS URL shown in the console. On first run — while no SystemAdmin exists yet — the app opens the /setup wizard: create the first SystemAdmin (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) — a public virtual-method-per-message class the library registers in English by default; the example provides a full Spanish override (BlazorExample/Extensions/SpanishMyIdentityMessageDescriber.cs), registered via AddMyIdentityMessageDescriber<T>() right after AddMyIdentity(...). 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 (see GETTING_STARTED.md §9). ErrorCode stays available on MyIdentityCustomException for branching logic, not for producing text (see GETTING_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, via IdentityErrorDescriber and AddMyIdentity(..., configureIdentityBuilder: ib => ib.AddErrorDescriber<YourDescriber>()) — the package ships none, so it defaults to English (the example provides SpanishIdentityErrorDescriber). To localize the rest of the UI, translate the .razor literals and set your culture in app.UseRequestLocalization(...) (the example uses es-AR); the short login-redirect codes (?error=...) the AuthController produces are separately mapped to text by BlazorExampleErrorMapper.MapLoginError.


Features

  • Layered, batteries-included-but-replaceable integration. The typical path is a base DbContext (MyIdentityDbContext<TUser,TTenant>) + one AddMyIdentity call + 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 typed MyIdentityOptions object, 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-module SupportedPermissions, fixed Modify/ExecuteRead implications, 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 + Secure cookies, 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 Google ClientId / ClientSecret are never shipped in the package. Startup fails fast if a feature is enabled with a missing, placeholder, or too-short (< 32 bytes) secret. Keep secrets in User Secrets (dev) or environment variables / a key vault (prod).
  • No seeded admin; first-run setup wizard instead. MyIdentitySeedDataAsync no 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 no SystemAdmin exists, the app gates on a first-run /setup wizard (full name, email, password) that creates the first SystemAdmin, holding both SystemAdmin (cross-tenant) and TenantAdmin of 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.OnTokenValidated delegate that AddMyIdentity installs from a PostConfigure<JwtBearerOptions>. Two things silently void it: assigning options.Events (or options.Events.OnTokenValidated) from your own PostConfigure<JwtBearerOptions> registered after AddMyIdentity, and setting options.EventsType — with EventsType set the handler resolves its events from DI and ignores options.Events altogether. In either case revoked JWTs keep working until they expire, with no error and no revalidator behind them. Chain, never replace: capture the existing OnTokenValidated and await it from yours.
  • Anti-enumeration on login. LoginAsync returns a MyIdentityLoginResult whose Status (MyIdentityLoginStatus) distinguishes failure reasons — InvalidCredentials, LockedOut, RequiresEmailConfirmation, RequiresTwoFactor, NotAllowed, TenantValidationFailed, RequiresPasswordChange — for server-side logging/diagnostics. A disabled or deleted account maps to NotAllowed; a locked-out account maps to LockedOut. The consumer MUST collapse every non-success status other than RequiresPasswordChange to a single generic user-facing message (e.g. "invalid credentials"). Never render UI text that varies per Status; 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, via MyIdentity: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, AddMyIdentity throws 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, set MyIdentity:Authentication:DataProtection:KeyProtection to None and chain your own provider (e.g. services.AddDataProtection().ProtectKeysWithAzureKeyVault(...)). Setting None is required, not optional, when you chain after AddMyIdentity: the library contributes its encryptor from an IConfigureOptions<KeyManagementOptions> registered inside AddMyIdentity, and options configuration runs in registration order — so a provider chained afterwards has not yet set XmlEncryptor by 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. None makes 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 before AddMyIdentity also works without None, because the library defers to an XmlEncryptor that 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 DbContext hierarchy, 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, handling MyIdentityCustomException in 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, every FineGrainedPermissions:Modules entry must be an object with a Code — a bare string is not bound by the configuration binder and is silently dropped. Omit SupportedPermissions to support all flags, or declare { "Code", "SupportedPermissions" } to restrict a module to a subset (e.g. no Execute) — enforced when saving a role's permission matrix. In C# code, declare each module explicitly with AddModule(code, perms) (or new MyIdentityModuleDefinition(code, perms)). The old bare-string form options.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-based AddModule helper, 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

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. SystemAdmin users bypass the tenant check entirely — membership is resolved through UserManager.IsInRoleAsync, i.e. by normalized name, so only the global SystemAdmin role earns the exemption (a tenant-owned role merely named "SystemAdmin" normalizes to {tenantId}:SYSTEMADMIN and does not). For everyone else the check runs in order and can reject at four points, all collapsed into a single generic TenantValidationFailed. Two of them are not governed by MyIdentity: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 whose TenantId is null passes both.
  • Added only when UseTenantOnLogin is true: (3) the user must have a tenant assigned at all, and (4) a supplied TradingName (fantasy name) must match that tenant's — case-insensitive and trimmed ("empresa1" matches "Empresa1"). An empty/omitted TradingName skips only the name comparison (4), never (3). These two are logged only under MyIdentity:General:DebugLogEnabled (default false) — enable it and consult your server-side logs, not the UI, when diagnosing them.

So leaving UseTenantOnLogin off does not keep a non-SystemAdmin member 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-SystemAdmin account (a native TenantAdmin with no tenant included), even when no name is sent at all. The reference app surfaces TenantValidationFailed as an "invalid credentials" message (anti-enumeration). This behavior is driven only by server-side configuration; MyIdentityLoginDTO has 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 AuthenticationSchemes on bearer-only endpoints. AddMyIdentity registers 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 valid Authorization: Bearer <token> header, with no auth cookie, is silently treated as anonymous (redirected, not rejected with 401), and the JWT bearer handler never even runs. Pin the scheme explicitly: [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]. The reference app's RestAPISampleController does this; see BlazorExample/DEMO-COVERAGE.md for 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-bound HttpContext) is authorized against HttpContext.User, not the Blazor circuit's AuthenticationStateProvider state. See UPGRADE.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]ProcessGoogleResponseAsync reads that exact key back on the callback. MyIdentityAuthProperties.TenantName (in MyIdentity.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 IMyIdentityRoleServicenot 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
Loading failed

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.