diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..552e146 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,196 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-04-29 + +Phase 1 — Canonical Identity Migration. Major version bump due to +breaking schema, API, and behavior changes. Consumers must run the +canonical identity data migration before deploying this version against +existing data. Multi-instance deployments require blue/green; rolling +restart is unsupported across the v1.x → v2.0.0 boundary. + +### Breaking Changes + +- `IdmtUser` is global, not per-tenant. The `TenantId` column is dropped + from `AspNetUsers`. `GetTenantId()` returns null. The + `(Email, UserName, TenantId)` composite unique index is replaced by a + global unique index on `NormalizedEmail`. One identity row per human; + cross-tenant access is gated by `TenantAccess` for every user + (including SysAdmin) per locked decision #4. +- `IdmtDefaultRoleTypes.DefaultRoles` no longer contains `SysAdmin` or + `SysSupport`. SysAdmin / SysSupport identities are now expressed via + `IdmtUser.SysRole` and projected as role-string claims at sign-in. + The string constants `IdmtDefaultRoleTypes.SysAdmin` / + `IdmtDefaultRoleTypes.SysSupport` remain unchanged so existing + `RequireRole` / `RequireSysAdmin` / `RequireSysUser` policies match + without code change. Pre-existing per-tenant `AspNetRoles` rows for + SysAdmin / SysSupport become inert after migration. +- `LoginHandler` and `TokenLoginHandler` now reject authentication when + the credential-verified user has no active `TenantAccess` row for the + request's resolved tenant. The check fires after + `CheckPasswordSignInAsync` (no enumeration oracle: tenant mismatch and + bad password share the `Auth.Unauthorized` response) and before any + cookie or token is issued. Closes KR-1. +- `RegisterUser` now writes a `TenantAccess` row for the inviting tenant + in the same transaction as user creation. Without this, the + TenantAccess gate would lock newly registered users out on their next + request. +- `CreateTenantHandler` now requires `ICurrentUserService` and inserts + `TenantAccess(invokerUserId, newTenantId, IsActive=true)` in the same + inner-scope transaction as default-role seeding. Boot-time seeding + paths must use `IMultiTenantStore` + `ITenantOperationService` + directly — the handler is fail-closed when no current user is + resolved. +- `ConfirmEmailRequest` and `ResetPasswordRequest` no longer accept + `TenantIdentifier` in the body. Tenant context is derived from the + ambient request strategy. The body field is silently ignored if sent. +- `IIdmtLinkGenerator.GenerateConfirmEmailLink` / + `GeneratePasswordResetLink` no longer embed `tenantIdentifier` as a + query parameter in either the ServerConfirm or ClientForm branches. + Route-based tenant strategies still inject the configured route + token, so `/{tenant}/...` links are unaffected. Consumer SPAs that + read `tenantIdentifier` from the link URL and echoed it back in the + body must switch to host/path-based tenant routing. +- `ResetPassword` no longer flips `EmailConfirmed = true` as a side + effect of a successful reset. Email confirmation must travel through + its own confirm-email flow. +- `PUT /manage/info` no longer mutates `Email` immediately when + `NewEmail` is set. The new address is staged in + `IdmtUser.PendingEmail` and a confirmation link is sent to that + address; `Email` is committed only when the recipient POSTs to + `POST /auth/confirm-email-change` with the token. The endpoint + returns `202 Accepted` (Location: `/auth/confirm-email-change`) + instead of `200 OK` in this case. Existing clients that treated 200 + as success must accept 202 and surface a "check your inbox" prompt. +- `RevokeTenantAccess` revokes by canonical `UserId` only; the prior + shadow-user deactivation path inside `ExecuteInTenantScopeAsync` is + removed. +- `GrantTenantAccess` no longer creates shadow `IdmtUser` rows; it + writes only `TenantAccess` plus optional `IdentityUserRole` rows in + a single transaction. + +### Added + +- `Idmt.Plugin/Models/SysRoleKind.cs` — `None=0`, `SysAdmin=1`, + `SysSupport=2`. Enum string values are deliberately equal to the + policy strings `"SysAdmin"` / `"SysSupport"` so `RequireRole` matches + without bridge code. +- `IdmtUser.SysRole` column on `AspNetUsers` — global system-role flag. +- `IdmtUser.PendingEmail` column on `AspNetUsers` — bare nullable + string staging the next email until OOB confirmation. +- `POST /auth/confirm-email-change` (AllowAnonymous) — verifies the + Identity-issued token via `ChangeEmailAsync`, atomically commits + `Email` + `EmailConfirmed`, rotates the security stamp, and clears + `PendingEmail`. +- `IIdmtLinkGenerator.GenerateConfirmEmailChangeLink` and + `ApplicationOptions.ConfirmEmailChangeFormPath` (default + `/confirm-email-change`). No `tenantIdentifier` embedded. +- `Idmt.Plugin/Migration/CanonicalIdentityDataMigrator/` — dry-run / + apply harness with SHA-256 plan-fingerprint ack handshake. Bulk + rewrites (`TenantAccess.UserId`, `IdmtAuditLog.UserId`, + `IdentityUserRole`, `AspNetUserTokens`, legacy `RevokedToken` + deletion, duplicate `IdmtUser` deletion, `SysRole` fold, + per-survivor `SecurityStamp` rotation) run inside a single + `BeginTransactionAsync` / `CommitAsync` block so any + `SaveChangesAsync` failure rolls everything back. +- `tools/Idmt.Migrator` — net10.0 console host. Args: + `--dry-run`, `--apply`, + `--ack-dryrun-fingerprint `, + `--accept-cross-tenant-merges `, + `--provider {sqlite,sqlserver}`. +- New errors: `Email.NoPendingChange`, `Email.PendingMismatch`. +- `IdmtUserClaimsPrincipalFactory` emits a `SysRole` claim when the + user's `SysRole != None` and sources the tenant claim from the + ambient `IMultiTenantContextAccessor`. Throws + `InvalidOperationException` if the ambient context is null + (fail-closed, CD-4). + +### Removed + +- `IdmtUser.TenantId` column. +- `IsMultiTenant()` on `IdmtUser` in `IdmtDbContext`. +- The `(Email, UserName, TenantId)` composite unique index on + `AspNetUsers`. +- Default per-tenant `SysAdmin` / `SysSupport` rows from + `IdmtDefaultRoleTypes.DefaultRoles`. +- `tenantIdentifier` query parameter from confirm-email and + password-reset URLs. +- `TenantIdentifier` field from `ConfirmEmailRequest` and + `ResetPasswordRequest`. +- The `EmailConfirmed = true` side effect from `ResetPassword`. +- Shadow-user creation in `GrantTenantAccess` and shadow-user + deactivation in `RevokeTenantAccess`. + +### Migration + +Required before deploying v2.0.0 against existing v1.x data. Detailed +runbook lives in `SECURITY_PHASE_1_CANONICAL_IDENTITY.md` §5 and the +v3 plan §D / §E. Short version: + +1. Snapshot `Phase0SchemaSnapshot.sql` from the v1.x deployment. +2. Stop writes (blue/green cutover; rolling restart is not supported). +3. Backup the database. +4. `dotnet run --project tools/Idmt.Migrator -- --dry-run + --provider sqlserver --connection ""`. Review the divergence + report and capture the SHA-256 plan fingerprint plus any + cross-tenant merge group ids. +5. `dotnet run --project tools/Idmt.Migrator -- --apply + --ack-dryrun-fingerprint "" + --accept-cross-tenant-merges "${ACCEPT_GROUP_IDS:-}" + --provider sqlserver --connection ""`. Migrator refuses to + run if the recomputed fingerprint diverges from the ack value. +6. Apply the EF schema migration that drops `IdmtUser.TenantId`, adds + `SysRole` + `PendingEmail`, and replaces the unique index. +7. Deploy the v2.0.0 image into the green slot and cut traffic. + +Audit emission during migration uses the literal sentinel TenantId +`"__migration__"` for migrator-emitted rows; query with this sentinel +to isolate migration audit traffic. + +Pre-migration password-reset tokens are invalidated by the migration's +per-survivor SecurityStamp rotation. Issue fresh links if any are +in-flight. + +### Security Fixes + +- **Audit C3** — `ConfirmEmail` no longer trusts `TenantIdentifier` + from the request body; tenant context is derived from the ambient + resolver. Closes the cross-tenant confirmation oracle. +- **Audit C4** — `ResetPassword` no longer trusts request-body + `TenantIdentifier` and no longer flips `EmailConfirmed = true` on + success. Closes the email-confirm-via-password-reset takeover leg. +- **Audit C7** — `PUT /manage/info` stages email change in + `PendingEmail` and routes confirmation through OOB + `POST /auth/confirm-email-change`. An attacker holding a session + cookie can no longer rebind `Email` to an address they control + without proving control of the new mailbox. +- **Audit N1** — Login enforces a uniform `TenantAccess` gate (no + SysRole short-circuit). A user with credentials in tenant A can no + longer log in to tenant B by hitting B's login endpoint. +- **Audit N3** — `IdmtUserClaimsPrincipalFactory` is fail-closed when + the ambient `IMultiTenantContextAccessor` is null, preventing + silently-tenant-less principals from being constructed during + background work. +- **Audit H7** — `IdmtLinkGenerator` no longer embeds + `tenantIdentifier` in confirm-email or password-reset URLs; + hardened `AddTenantRouteParameter` skips injection when a custom + route strategy is configured under the literal name + `"tenantIdentifier"`. + +### Security Notes + +- **R18 (deferred to Phase 4).** `UpdateUserInfo` dropped its + `FindByEmailAsync` pre-check on `NewEmail` to avoid an enumeration + oracle. The trade-off is a third-party email-spam vector when + `PUT /manage/info` is unrate-limited. The in-plugin `RateLimiting` + option defaults to disabled (post-`cc4ab61`); consumers must opt in + today. Phase 4 will wire `PUT /manage/info` into a per-user + rate-limit policy by default. +- **KR-1 / KR-2 / KR-3 (residual).** Bearer / cookie tickets minted + before TenantAccess revocation, before migration, or against a user + with `EmailConfirmed=false` post-merge remain valid until natural + expiry. Phase 2 closes the bearer-revocation enforcement and refresh + rotation gaps; tests F31, HS-10, and F38 pin the regression windows. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d50ac8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IDMT (Identity MultiTenant) Plugin — a reusable NuGet library for ASP.NET Core providing multi-tenant identity management. Built on Finbuckle.MultiTenant and ASP.NET Core Identity with per-tenant cookie isolation, hybrid cookie/bearer authentication, and vertical slice architecture. Uses ErrorOr for result handling and FluentValidation for request validation. + +**Target:** net10.0 + +## Build & Development Commands + +```bash +# Build +dotnet build Idmt.slnx +dotnet build Idmt.slnx --configuration Release + +# Format (CI enforces this) +dotnet format Idmt.slnx --verify-no-changes --verbosity diagnostic # check +dotnet format Idmt.slnx # fix + +# Test +dotnet test Idmt.slnx +dotnet test tests/Idmt.UnitTests/Idmt.UnitTests.csproj # unit only +dotnet test tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj # integration only +dotnet test --filter "FullyQualifiedName~TenantAccessServiceTests" # single test class + +# Pack +dotnet pack Idmt.Plugin/Idmt.Plugin.csproj --configuration Release +``` + +CI runs: format check → build (warnings as errors) → analyzers → tests → pack. + +## Architecture + +### Vertical Slice Pattern + +Each feature (Login, ForgotPassword, CreateTenant, etc.) is a self-contained static class in `Idmt.Plugin/Features/` containing: + +- Request/Response records +- Handler interface returning `ErrorOr` +- Internal sealed handler implementation +- FluentValidation validator (registered via DI auto-discovery) +- Endpoint mapping method using Minimal APIs + +Features are grouped into: `Auth/`, `Manage/`, `Admin/`, `Health/`. Endpoints are mapped via `AuthEndpoints.cs`, `ManageEndpoints.cs`, and `AdminEndpoints.cs`. + +### Error Handling + +All handlers return `ErrorOr`. Centralized error definitions in `Idmt.Plugin/Errors/IdmtErrors.cs` organized by domain (Auth, Tenant, User, Token, Email, Password, General). Endpoint delegates map `ErrorType` to HTTP status codes. + +### Multi-Tenancy + +- **Finbuckle.MultiTenant** resolves tenants via configurable strategies (Header, Route, Claim, BasePath) +- `IdmtUser` extends `IdentityUser` and is **global** (not multi-tenant). `GetTenantId()` returns null. One identity row per human; `NormalizedEmail` is globally unique. +- `IdmtRole` remains per-tenant. The default role catalog (`IdmtDefaultRoleTypes.DefaultRoles`) was shrunk and no longer seeds `SysAdmin` / `SysSupport` per tenant. +- `IdmtUser.SysRole` (`SysRoleKind` enum: `None` / `SysAdmin` / `SysSupport`) is a global system-role flag projected as a role-string claim at sign-in. Enum string values equal the policy strings so `RequireRole` / `RequireSysAdmin` / `RequireSysUser` match without bridge code. +- `TenantAccess` maps users to tenants with `IsActive` and optional `ExpiresAt`. The TenantAccess gate is **uniform** across all users (including SysAdmin) per locked decision #4 — there is no SysRole short-circuit. `LoginHandler` / `TokenLoginHandler` enforce it after `CheckPasswordSignInAsync` and before any cookie/token is issued. +- Password and `SecurityStamp` are single-source on the canonical user row. Rotations propagate everywhere automatically — no shadow rows to keep in sync. +- `IdmtUser.PendingEmail` (nullable string) stages the next email during the OOB email-change flow. `Email` is committed only when the recipient POSTs to `/auth/confirm-email-change` with the Identity-issued token (returns 202 Accepted from `PUT /manage/info` while staged). +- Per-tenant cookie isolation: each tenant gets a separate authentication cookie name +- `ValidateBearerTokenTenantMiddleware` ensures bearer token tenant matches request tenant +- Two EF contexts: `IdmtDbContext` (multi-tenant app data) and `IdmtTenantStoreDbContext` (tenant metadata) +- `ITenantOperationService` executes code within a tenant-scoped DI scope. Invariant: inner-scope `CurrentUserService.User` must stay null; capture invoker context outside `ExecuteInTenantScopeAsync`. + +### Authentication & Authorization + +- **PolicyScheme** (`CookieOrBearerScheme`) auto-selects cookie vs bearer based on `Authorization` header +- Pre-configured policies: `RequireSysAdmin`, `RequireSysUser`, `RequireTenantManager`, `CookieOnly`, `BearerOnly` +- Token revocation via `ITokenRevocationService` with background cleanup (`TokenRevocationCleanupService`) + +### Key Services + +- `ICurrentUserService` (scoped) — current user, tenant, IP, user agent context +- `ITenantAccessService` — tenant access validation +- `ITokenRevocationService` — bearer token revocation store +- `IIdmtLinkGenerator` — email confirmation/password reset link generation +- `PiiMasker` — masks emails in structured logs + +### DI Entry Point + +`AddIdmt()` extension method in `ServiceCollectionExtensions` with parameters: `configuration`, `configureDb`, `configureOptions`, `customizeAuthentication`, `customizeAuthorization`. + +## Testing + +- **Unit tests** (`tests/Idmt.UnitTests`): xUnit + Moq + EF InMemory + TimeProvider.Testing +- **Integration tests** (`tests/Idmt.BasicSample.Tests`): xUnit + `Microsoft.AspNetCore.Mvc.Testing` with in-memory SQLite + - `IdmtApiFactory` — WebApplicationFactory with mocked email sender and test data seeding + - `BaseIntegrationTest` — helpers for authenticated requests and token extraction + +## Key References + +- [Finbuckle.MultiTenant Docs](https://www.finbuckle.com/MultiTenant/Docs/) +- [Finbuckle GitHub](https://github.com/Finbuckle/Finbuckle.MultiTenant) (check older tag samples like v8.0.0) +- [ASP.NET Core Identity](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity) + +## Commit Conventions + +- Do NOT add `Co-Authored-By: Claude` (or any AI attribution) trailers to commit messages. Author the commit as the user only. diff --git a/Idmt.Plugin/Configuration/IdmtOptions.cs b/Idmt.Plugin/Configuration/IdmtOptions.cs index 49e4878..7095dad 100644 --- a/Idmt.Plugin/Configuration/IdmtOptions.cs +++ b/Idmt.Plugin/Configuration/IdmtOptions.cs @@ -87,6 +87,7 @@ public class ApplicationOptions public string ResetPasswordFormPath { get; set; } = "/reset-password"; public string ConfirmEmailFormPath { get; set; } = "/confirm-email"; + public string ConfirmEmailChangeFormPath { get; set; } = "/confirm-email-change"; /// /// Controls how email confirmation links are generated. diff --git a/Idmt.Plugin/Errors/IdmtErrors.cs b/Idmt.Plugin/Errors/IdmtErrors.cs index 48d667a..eab0760 100644 --- a/Idmt.Plugin/Errors/IdmtErrors.cs +++ b/Idmt.Plugin/Errors/IdmtErrors.cs @@ -135,6 +135,14 @@ public static class Email public static Error ConfirmationFailed => Error.Failure( code: "Email.ConfirmationFailed", description: "Unable to confirm email"); + + public static Error NoPendingChange => Error.Validation( + code: "Email.NoPendingChange", + description: "No pending email change to confirm."); + + public static Error PendingMismatch => Error.Validation( + code: "Email.PendingMismatch", + description: "Pending email does not match request."); } public static class Password @@ -149,5 +157,9 @@ public static class General public static Error Unexpected => Error.Unexpected( code: "General.Unexpected", description: "An unexpected error occurred"); + + public static Error SelfTarget => Error.Forbidden( + code: "General.SelfTarget", + description: "This operation cannot target the caller"); } } diff --git a/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs b/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs index a26b176..d85e3b0 100644 --- a/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs +++ b/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs @@ -171,16 +171,68 @@ public static async Task SeedIdmtDataAsync(this IApplicatio private static async Task SeedDefaultDataAsync(IServiceProvider services) { + // Phase 1 / Step 9: bootstrap the default tenant directly via the tenant store + role + // seeding rather than the admin CreateTenant handler. The handler now requires an + // authenticated invoker (HS-4 / V2-CRIT-2 fail-closed) so it cannot run during app + // startup where no HTTP context (and therefore no current user) exists. var options = services.GetRequiredService>(); - var createTenantHandler = services.GetRequiredService(); - var result = await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( - MultiTenantOptions.DefaultTenantIdentifier, - options.Value.MultiTenant.DefaultTenantName)); + var tenantStore = services.GetRequiredService>(); + var defaultIdentifier = MultiTenantOptions.DefaultTenantIdentifier; - if (result.IsError && result.FirstError.Code != "Tenant.AlreadyExists") + var existing = await tenantStore.GetByIdentifierAsync(defaultIdentifier); + IdmtTenantInfo defaultTenant; + if (existing is null) + { + defaultTenant = new IdmtTenantInfo(defaultIdentifier, options.Value.MultiTenant.DefaultTenantName); + if (!await tenantStore.AddAsync(defaultTenant)) + { + throw new InvalidOperationException( + $"Failed to seed default tenant '{defaultIdentifier}'."); + } + } + else if (!existing.IsActive) + { + defaultTenant = existing with { IsActive = true }; + if (!await tenantStore.UpdateAsync(defaultTenant)) + { + throw new InvalidOperationException( + $"Failed to reactivate default tenant '{defaultIdentifier}'."); + } + } + else + { + defaultTenant = existing; + } + + // Seed default roles inside the tenant scope. + var tenantOps = services.GetRequiredService(); + var roles = IdmtDefaultRoleTypes.DefaultRoles; + if (options.Value.Identity.ExtraRoles.Length > 0) + { + roles = [.. roles, .. options.Value.Identity.ExtraRoles]; + } + + var seedResult = await tenantOps.ExecuteInTenantScopeAsync(defaultTenant.Identifier!, async provider => + { + var roleManager = provider.GetRequiredService>(); + foreach (var role in roles) + { + if (!await roleManager.RoleExistsAsync(role)) + { + var createResult = await roleManager.CreateAsync(new IdmtRole(role)); + if (!createResult.Succeeded) + { + return Errors.IdmtErrors.Tenant.RoleSeedFailed; + } + } + } + return ErrorOr.Result.Success; + }, requireActive: false); + + if (seedResult.IsError) { throw new InvalidOperationException( - $"Failed to seed default tenant '{MultiTenantOptions.DefaultTenantIdentifier}': {result.FirstError.Description}"); + $"Failed to seed default tenant '{defaultIdentifier}' roles: {seedResult.FirstError.Description}"); } } diff --git a/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs index ab98637..2340ae1 100644 --- a/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs +++ b/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs @@ -475,6 +475,7 @@ private static void RegisterFeatures(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Idmt.Plugin/Features/Admin/CreateTenant.cs b/Idmt.Plugin/Features/Admin/CreateTenant.cs index 60f34c7..c951723 100644 --- a/Idmt.Plugin/Features/Admin/CreateTenant.cs +++ b/Idmt.Plugin/Features/Admin/CreateTenant.cs @@ -4,8 +4,10 @@ using Idmt.Plugin.Configuration; using Idmt.Plugin.Errors; using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; using Idmt.Plugin.Services; using Idmt.Plugin.Validation; +using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; @@ -39,11 +41,22 @@ public interface ICreateTenantHandler internal sealed class CreateTenantHandler( IMultiTenantStore tenantStore, ITenantOperationService tenantOps, + ICurrentUserService currentUserService, IOptions options, ILogger logger) : ICreateTenantHandler { public async Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default) { + // V2-CRIT-2 / HS-4: capture invoker UserId in OUTER scope. CurrentUserService is Scoped + // and inside TenantOperationService.ExecuteInTenantScopeAsync's child DI scope, + // CurrentUserService.User is null (invariant #11). Reads inside the inner scope return + // null/Guid.Empty. Pass invokerUserId by value into the inner-scope work. + var invokerUserId = currentUserService.UserId; + if (invokerUserId is null) + { + return IdmtErrors.Auth.Unauthorized; + } + IdmtTenantInfo resultTenant; try @@ -71,6 +84,17 @@ public async Task> HandleAsync(CreateTenantRequest { return IdmtErrors.Tenant.CreationFailed; } + + // V2-CRIT-2 defensive guard: IdmtTenantInfo's ctor assigns Id from + // Guid.CreateVersion7() so this is non-null in the canonical path. + // If a custom store contract ever reassigns Id post-AddAsync and leaves it + // empty, fail hard before BootstrapTenantAsync would insert TenantAccess + // with a null TenantId. + if (string.IsNullOrEmpty(tenant.Id)) + { + logger.LogError("Tenant store did not populate Id for tenant {Identifier}", request.Identifier); + return IdmtErrors.Tenant.CreationFailed; + } resultTenant = tenant; } } @@ -82,7 +106,7 @@ public async Task> HandleAsync(CreateTenantRequest try { - bool ok = await GuaranteeTenantRolesAsync(resultTenant); + bool ok = await BootstrapTenantAsync(resultTenant, invokerUserId.Value); if (!ok) { return IdmtErrors.Tenant.RoleSeedFailed; @@ -90,7 +114,7 @@ public async Task> HandleAsync(CreateTenantRequest } catch (Exception ex) { - logger.LogError(ex, "Error seeding roles for tenant {Identifier}", request.Identifier); + logger.LogError(ex, "Error bootstrapping tenant {Identifier}", request.Identifier); return IdmtErrors.Tenant.RoleSeedFailed; } @@ -100,7 +124,12 @@ public async Task> HandleAsync(CreateTenantRequest resultTenant.Name ?? string.Empty); } - private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo) + /// + /// Seeds default per-tenant roles AND grants the invoker (SysAdmin) + /// in a single inner-scope SaveChanges. Without the auto-TenantAccess the invoker would be + /// locked out of the tenant they just created (Phase 1 uniform TenantAccess gate). + /// + private async Task BootstrapTenantAsync(IdmtTenantInfo tenantInfo, Guid invokerUserId) { var roles = IdmtDefaultRoleTypes.DefaultRoles; if (options.Value.Identity.ExtraRoles.Length > 0) @@ -111,17 +140,55 @@ private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo) var result = await tenantOps.ExecuteInTenantScopeAsync(tenantInfo.Identifier!, async provider => { var roleManager = provider.GetRequiredService>(); - foreach (var role in roles) + var dbContext = provider.GetRequiredService(); + var tenantId = tenantInfo.Id!; + + // V2-CRIT-1: wrap role seeding + invoker TenantAccess insertion in a single + // ambient transaction. RoleManager.CreateAsync persists each role internally via + // the same DbContext, so the ambient transaction governs every role row plus the + // TenantAccess row — if any step throws or fails, all changes roll back together. + // Without this wrap, partial role rows could persist while TenantAccess insert + // fails, locking the invoker out of the tenant they just bootstrapped. + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + try { - if (!await roleManager.RoleExistsAsync(role)) + foreach (var role in roles) { - var createResult = await roleManager.CreateAsync(new IdmtRole(role)); - if (!createResult.Succeeded) + if (!await roleManager.RoleExistsAsync(role)) { - return IdmtErrors.Tenant.RoleSeedFailed; + var createResult = await roleManager.CreateAsync(new IdmtRole(role)); + if (!createResult.Succeeded) + { + await transaction.RollbackAsync(); + return IdmtErrors.Tenant.RoleSeedFailed; + } } } + + // HS-4 / V2-CRIT-2: invoker auto-TenantAccess in same inner DI scope as role seeding. + // Phase 1 uniform gate requires every accessor (incl. SysAdmin) to have a TenantAccess row. + var alreadyHasAccess = await dbContext.TenantAccess + .AnyAsync(ta => ta.UserId == invokerUserId && ta.TenantId == tenantId); + if (!alreadyHasAccess) + { + dbContext.TenantAccess.Add(new TenantAccess + { + UserId = invokerUserId, + TenantId = tenantId, + IsActive = true, + ExpiresAt = null + }); + await dbContext.SaveChangesAsync(); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; } + return Result.Success; }, requireActive: false); @@ -154,6 +221,7 @@ public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBui var apiPrefix = context.RequestServices.GetRequiredService>().Value.Application.ApiPrefix ?? string.Empty; return TypedResults.Created($"{apiPrefix}/admin/tenants/{response.Value.Identifier}", response.Value); }) + .RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy) .WithSummary("Create Tenant") .WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant"); } diff --git a/Idmt.Plugin/Features/Admin/DeleteTenant.cs b/Idmt.Plugin/Features/Admin/DeleteTenant.cs index bc9cdb6..7c5ed2f 100644 --- a/Idmt.Plugin/Features/Admin/DeleteTenant.cs +++ b/Idmt.Plugin/Features/Admin/DeleteTenant.cs @@ -71,7 +71,7 @@ public static RouteHandlerBuilder MapDeleteTenantEndpoint(this IEndpointRouteBui } return TypedResults.NoContent(); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy) .WithSummary("Delete tenant") .WithDescription("Soft deletes a tenant by its identifier"); } diff --git a/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index 8cffe29..9704881 100644 --- a/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Idmt.Plugin.Features.Admin; @@ -26,39 +25,47 @@ public interface IGrantTenantAccessHandler Task> HandleAsync(Guid userId, string tenantIdentifier, DateTimeOffset? expiresAt = null, CancellationToken cancellationToken = default); } - // Issue 19 fix: inject IdmtDbContext, UserManager, and IMultiTenantStore - // as constructor parameters rather than resolving them from a manually-created IServiceProvider - // scope. The manual scope bypassed the request lifetime, causing audit-log fields that depend on - // ICurrentUserService (resolved through the request scope) to be null. + // Phase 1 (canonical identity): GrantTenantAccess writes ONLY a TenantAccess row in a single + // SaveChangesAsync transaction. No shadow IdmtUser is created in the target tenant; IdmtUser is + // a global entity post Phase 1. No ExecuteInTenantScopeAsync hop, no compensation. The Phase 0 + // self-grant guard at the top of HandleAsync remains in place per the architectural rule that + // self-grants happen only as a CreateTenant side-effect — never as a first-class HTTP op. internal sealed class GrantTenantAccessHandler( IdmtDbContext dbContext, UserManager userManager, IMultiTenantStore tenantStore, - ITenantOperationService tenantOps, + ICurrentUserService currentUserService, TimeProvider timeProvider, ILogger logger ) : IGrantTenantAccessHandler { public async Task> HandleAsync(Guid userId, string tenantIdentifier, DateTimeOffset? expiresAt = null, CancellationToken cancellationToken = default) { + if (currentUserService.UserId is null) + { + return IdmtErrors.Auth.Unauthorized; + } + + if (userId == currentUserService.UserId.Value) + { + return IdmtErrors.General.SelfTarget; + } + if (expiresAt.HasValue && expiresAt.Value <= timeProvider.GetUtcNow()) { return Error.Validation("ExpiresAt", "Expiration date must be in the future"); } - IdmtUser? user; - IdmtTenantInfo? targetTenant; - IList userRoles; - try { - user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + // Canonical (global) IdmtUser lookup. + var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) { return IdmtErrors.User.NotFound; } - targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier); + var targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier); if (targetTenant is null) { return IdmtErrors.Tenant.NotFound; @@ -69,155 +76,38 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti return IdmtErrors.Tenant.Inactive; } - userRoles = await userManager.GetRolesAsync(user); - if (userRoles.Count == 0) - { - logger.LogWarning("User {UserId} has no roles assigned; cannot grant tenant access.", userId); - return IdmtErrors.User.NoRolesAssigned; - } - var tenantAccess = await dbContext.TenantAccess - .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == targetTenant.Id, cancellationToken); + .FirstOrDefaultAsync(ta => ta.UserId == user.Id && ta.TenantId == targetTenant.Id, cancellationToken); if (tenantAccess is not null) { tenantAccess.IsActive = true; tenantAccess.ExpiresAt = expiresAt; - dbContext.TenantAccess.Update(tenantAccess); } else { - tenantAccess = new TenantAccess + dbContext.TenantAccess.Add(new TenantAccess { - UserId = userId, - TenantId = targetTenant.Id, + UserId = user.Id, + TenantId = targetTenant.Id!, IsActive = true, ExpiresAt = expiresAt - }; - dbContext.TenantAccess.Add(tenantAccess); - } - } - catch (Exception ex) - { - logger.LogError(ex, "Error granting tenant access to user {UserId} for tenant {TenantIdentifier}", userId, tenantIdentifier); - return IdmtErrors.Tenant.AccessError; - } - - // Execute tenant-scope operation BEFORE persisting TenantAccess to prevent orphaned records - var tenantResult = await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async tsp => - { - try - { - var targetUserManager = tsp.GetRequiredService>(); - - var targetUser = await targetUserManager.Users - .FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken); - - if (targetUser is null) - { - targetUser = new IdmtUser - { - UserName = user.UserName, - Email = user.Email, - EmailConfirmed = user.EmailConfirmed, - PasswordHash = user.PasswordHash, - // SecurityStamp and ConcurrencyStamp intentionally omitted — - // UserManager.CreateAsync generates fresh values so that session - // invalidation in one tenant does not affect the other. - PhoneNumber = user.PhoneNumber, - PhoneNumberConfirmed = user.PhoneNumberConfirmed, - TwoFactorEnabled = user.TwoFactorEnabled, - LockoutEnd = user.LockoutEnd, - LockoutEnabled = user.LockoutEnabled, - AccessFailedCount = user.AccessFailedCount, - IsActive = true - }; - - var createResult = await targetUserManager.CreateAsync(targetUser); - if (!createResult.Succeeded) - { - logger.LogError("Failed to create user in target tenant: {Errors}", string.Join(", ", createResult.Errors.Select(e => e.Description))); - return IdmtErrors.Tenant.AccessError; - } - var roleResult = await targetUserManager.AddToRolesAsync(targetUser, userRoles); - if (!roleResult.Succeeded) - { - logger.LogError("Failed to assign roles in target tenant: {Errors}", string.Join(", ", roleResult.Errors.Select(e => e.Description))); - return IdmtErrors.Tenant.AccessError; - } - } - else - { - targetUser.IsActive = true; - await targetUserManager.UpdateAsync(targetUser); - } - - return Result.Success; + }); } - catch (Exception ex) - { - logger.LogError(ex, "Error granting tenant access to user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier); - return IdmtErrors.Tenant.AccessError; - } - }); - if (tenantResult.IsError) - { - return tenantResult; - } - - // Tenant-scope operation succeeded — now persist the TenantAccess record - try - { await dbContext.SaveChangesAsync(cancellationToken); + return Result.Success; } catch (Exception ex) { - logger.LogError(ex, - "Failed to save TenantAccess record for user {UserId} in tenant {TenantIdentifier}. " + - "Executing compensating action to deactivate user in target tenant.", - userId, tenantIdentifier); - - // Compensating action: deactivate the user in the target tenant - await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async tsp => - { - try - { - var compensationUserManager = tsp.GetRequiredService>(); - var orphanedUser = await compensationUserManager.Users - .FirstOrDefaultAsync(u => u.Email == user!.Email && u.UserName == user.UserName, cancellationToken); - - if (orphanedUser is not null) - { - orphanedUser.IsActive = false; - await compensationUserManager.UpdateAsync(orphanedUser); - logger.LogWarning( - "Compensating action completed: deactivated user {Email} in tenant {TenantIdentifier} " + - "after TenantAccess save failure.", - user!.Email, tenantIdentifier); - } - - return Result.Success; - } - catch (Exception compensationEx) - { - logger.LogCritical(compensationEx, - "CRITICAL: Compensating action failed for user {UserId} in tenant {TenantIdentifier}. " + - "Manual intervention required: user exists in target tenant without a TenantAccess record.", - userId, tenantIdentifier); - return IdmtErrors.Tenant.AccessError; - } - }); - + logger.LogError(ex, "Error granting tenant access to user {UserId} for tenant {TenantIdentifier}", userId, tenantIdentifier); return IdmtErrors.Tenant.AccessError; } - - return Result.Success; } } public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/users/{userId:guid}/tenants/{tenantIdentifier}", async Task> ( + return endpoints.MapPost("/users/{userId:guid}/tenants/{tenantIdentifier}", async Task> ( Guid userId, string tenantIdentifier, [FromBody] GrantAccessRequest request, @@ -231,12 +121,14 @@ public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRou { ErrorType.Validation => TypedResults.BadRequest(), ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Unauthorized => TypedResults.Unauthorized(), _ => TypedResults.InternalServerError(), }; } return TypedResults.Ok(); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy) .WithSummary("Grant user access to a tenant"); } } diff --git a/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index 1cd5acd..b48a11c 100644 --- a/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Idmt.Plugin.Features.Admin; @@ -23,24 +22,35 @@ public interface IRevokeTenantAccessHandler Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default); } - // Fix: inject IdmtDbContext, UserManager, and IMultiTenantStore - // as constructor parameters rather than resolving them from a manually-created IServiceProvider - // scope. The manual scope bypassed the request lifetime, causing audit-log fields that depend on - // ICurrentUserService (resolved through the request scope) to be null. + // Phase 1 (canonical identity): RevokeTenantAccess flips TenantAccess.IsActive = false in a single + // SaveChangesAsync transaction, then revokes outstanding bearer tokens by canonical UserId. No + // shadow IdmtUser deactivation in the target tenant — IdmtUser is global post Phase 1, so there + // is no per-tenant user row to flip. The Phase 0 self-target guard at the top of HandleAsync + // remains in place per the architectural rule that callers cannot revoke their own access. internal sealed class RevokeTenantAccessHandler( IdmtDbContext dbContext, + UserManager userManager, IMultiTenantStore tenantStore, - ITenantOperationService tenantOps, ITokenRevocationService tokenRevocationService, + ICurrentUserService currentUserService, ILogger logger) : IRevokeTenantAccessHandler { public async Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default) { - IdmtUser? user; + if (currentUserService.UserId is null) + { + return IdmtErrors.Auth.Unauthorized; + } + + if (userId == currentUserService.UserId.Value) + { + return IdmtErrors.General.SelfTarget; + } try { - user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + // Canonical (global) IdmtUser lookup. + var user = await userManager.FindByIdAsync(userId.ToString()); if (user is null) { return IdmtErrors.User.NotFound; @@ -53,50 +63,32 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti } var tenantAccess = await dbContext.TenantAccess - .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == targetTenant.Id, cancellationToken); + .FirstOrDefaultAsync(ta => ta.UserId == user.Id && ta.TenantId == targetTenant.Id, cancellationToken); if (tenantAccess is null) { return IdmtErrors.Tenant.AccessNotFound; } tenantAccess.IsActive = false; - dbContext.TenantAccess.Update(tenantAccess); await dbContext.SaveChangesAsync(cancellationToken); - // Revoke any active bearer tokens so the user cannot refresh after access is removed - await tokenRevocationService.RevokeUserTokensAsync(userId, targetTenant.Id!, cancellationToken); + // Revoke any active bearer tokens so the user cannot refresh after access is removed. + // Token revocation keys on canonical UserId — there is no shadow user under Phase 1. + await tokenRevocationService.RevokeUserTokensAsync(user.Id, targetTenant.Id!, cancellationToken); + + return Result.Success; } catch (Exception ex) { logger.LogError(ex, "Error revoking tenant access for user {UserId} and tenant {TenantIdentifier}", userId, tenantIdentifier); return IdmtErrors.Tenant.AccessError; } - - return await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async sp => - { - var tenantUserManager = sp.GetRequiredService>(); - try - { - var targetUser = await tenantUserManager.Users.FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken); - if (targetUser is not null) - { - targetUser.IsActive = false; - await tenantUserManager.UpdateAsync(targetUser); - } - return Result.Success; - } - catch (Exception ex) - { - logger.LogError(ex, "Error deactivating user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier); - return IdmtErrors.Tenant.AccessError; - } - }, requireActive: false); } } public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapDelete("/users/{userId:guid}/tenants/{tenantIdentifier}", async Task> ( + return endpoints.MapDelete("/users/{userId:guid}/tenants/{tenantIdentifier}", async Task> ( Guid userId, string tenantIdentifier, IRevokeTenantAccessHandler handler, @@ -107,13 +99,16 @@ public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRo { return result.FirstError.Type switch { + ErrorType.Validation => TypedResults.BadRequest(), ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Unauthorized => TypedResults.Unauthorized(), _ => TypedResults.InternalServerError(), }; } return TypedResults.NoContent(); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy) .WithSummary("Revoke user access from a tenant"); } } diff --git a/Idmt.Plugin/Features/AdminEndpoints.cs b/Idmt.Plugin/Features/AdminEndpoints.cs index dfe369d..10664b1 100644 --- a/Idmt.Plugin/Features/AdminEndpoints.cs +++ b/Idmt.Plugin/Features/AdminEndpoints.cs @@ -1,4 +1,3 @@ -using Idmt.Plugin.Configuration; using Idmt.Plugin.Features.Admin; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -11,7 +10,6 @@ public static class AdminEndpoints public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints) { var admin = endpoints.MapGroup("/admin") - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithTags("Admin"); admin.MapCreateTenantEndpoint(); @@ -21,4 +19,4 @@ public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints) admin.MapRevokeTenantAccessEndpoint(); admin.MapGetAllTenantsEndpoint(); } -} \ No newline at end of file +} diff --git a/Idmt.Plugin/Features/Auth/ConfirmEmail.cs b/Idmt.Plugin/Features/Auth/ConfirmEmail.cs index 8faec74..1e3721c 100644 --- a/Idmt.Plugin/Features/Auth/ConfirmEmail.cs +++ b/Idmt.Plugin/Features/Auth/ConfirmEmail.cs @@ -11,50 +11,45 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Idmt.Plugin.Features.Auth; public static class ConfirmEmail { - public sealed record ConfirmEmailRequest(string TenantIdentifier, string Email, string Token); + public sealed record ConfirmEmailRequest(string Email, string Token); public interface IConfirmEmailHandler { Task> HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default); } - internal sealed class ConfirmEmailHandler(ITenantOperationService tenantOps, ILogger logger) : IConfirmEmailHandler + internal sealed class ConfirmEmailHandler(UserManager userManager, ILogger logger) : IConfirmEmailHandler { public async Task> HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default) { - return await tenantOps.ExecuteInTenantScopeAsync(request.TenantIdentifier, async provider => + try { - var userManager = provider.GetRequiredService>(); - try + var user = await userManager.FindByEmailAsync(request.Email); + if (user == null) { - var user = await userManager.FindByEmailAsync(request.Email); - if (user == null) - { - return IdmtErrors.Email.ConfirmationFailed; - } - - var result = await userManager.ConfirmEmailAsync(user, request.Token!); + return IdmtErrors.Email.ConfirmationFailed; + } - if (!result.Succeeded) - { - return IdmtErrors.Email.ConfirmationFailed; - } + var result = await userManager.ConfirmEmailAsync(user, request.Token!); - return Result.Success; - } - catch (Exception ex) + if (!result.Succeeded) { - logger.LogError(ex, "Error confirming email for {Email} in tenant {TenantIdentifier}", PiiMasker.MaskEmail(request.Email), request.TenantIdentifier); - return IdmtErrors.General.Unexpected; + return IdmtErrors.Email.ConfirmationFailed; } - }); + + return Result.Success; + } + catch (Exception ex) + { + logger.LogError(ex, "Error confirming email for {Email}", PiiMasker.MaskEmail(request.Email)); + return IdmtErrors.General.Unexpected; + } } } @@ -104,7 +99,6 @@ public static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteBui public static RouteHandlerBuilder MapConfirmEmailDirectEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapGet("/confirm-email", async Task> ( - [FromQuery] string tenantIdentifier, [FromQuery] string email, [FromQuery] string token, [FromServices] IConfirmEmailHandler handler, @@ -121,7 +115,7 @@ public static RouteHandlerBuilder MapConfirmEmailDirectEndpoint(this IEndpointRo return TypedResults.BadRequest(); } - var request = new ConfirmEmailRequest(tenantIdentifier, email, decodedToken); + var request = new ConfirmEmailRequest(email, decodedToken); var result = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); if (result.IsError) diff --git a/Idmt.Plugin/Features/Auth/ConfirmEmailChange.cs b/Idmt.Plugin/Features/Auth/ConfirmEmailChange.cs new file mode 100644 index 0000000..9f5f0c6 --- /dev/null +++ b/Idmt.Plugin/Features/Auth/ConfirmEmailChange.cs @@ -0,0 +1,148 @@ +using ErrorOr; +using FluentValidation; +using Idmt.Plugin.Errors; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; +using Idmt.Plugin.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Idmt.Plugin.Features.Auth; + +public static class ConfirmEmailChange +{ + /// + /// Request to confirm a previously staged out-of-band email change. + /// Email is the user's CURRENT email (canonical lookup key). + /// NewEmail is the staged value previously written to PendingEmail. + /// Token is the change-email token issued by Identity at staging time. + /// + public sealed record ConfirmEmailChangeRequest(string Email, string NewEmail, string Token); + + public interface IConfirmEmailChangeHandler + { + Task> HandleAsync(ConfirmEmailChangeRequest request, CancellationToken cancellationToken = default); + } + + internal sealed class ConfirmEmailChangeHandler( + UserManager userManager, + IdmtDbContext dbContext, + ILogger logger) : IConfirmEmailChangeHandler + { + public async Task> HandleAsync( + ConfirmEmailChangeRequest request, + CancellationToken cancellationToken = default) + { + try + { + // Canonical lookup by current email — IdmtUser is global post-Phase 1. + var user = await userManager.FindByEmailAsync(request.Email); + if (user is null) + { + return IdmtErrors.Email.ConfirmationFailed; + } + + // Verify there is a pending email change matching the requested NewEmail. + if (string.IsNullOrEmpty(user.PendingEmail)) + { + return IdmtErrors.Email.NoPendingChange; + } + + if (!string.Equals(user.PendingEmail, request.NewEmail, StringComparison.OrdinalIgnoreCase)) + { + return IdmtErrors.Email.PendingMismatch; + } + + // ChangeEmailAsync atomically validates the token, updates Email + NormalizedEmail, + // sets EmailConfirmed = true, and rotates the SecurityStamp. Token bound to + // (user.Id, post-staging SecurityStamp, "ChangeEmail:newEmail" purpose). + var changeResult = await userManager.ChangeEmailAsync(user, request.NewEmail, request.Token); + if (!changeResult.Succeeded) + { + logger.LogWarning( + "ChangeEmailAsync failed for {Email}: {Errors}", + PiiMasker.MaskEmail(request.Email), + string.Join(", ", changeResult.Errors.Select(e => e.Description))); + return IdmtErrors.Email.ConfirmationFailed; + } + + // Clear staging slot. Reload first to pick up post-rotation stamp from + // ChangeEmailAsync, then null out PendingEmail and persist. + await dbContext.Entry(user).ReloadAsync(cancellationToken); + user.PendingEmail = null; + await dbContext.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "Email change confirmed. User: {UserId}.", + user.Id); + + return Result.Success; + } + catch (Exception ex) + { + logger.LogError(ex, "Error confirming email change for {Email}", PiiMasker.MaskEmail(request.Email)); + return IdmtErrors.General.Unexpected; + } + } + } + + /// + /// Endpoint mapping for POST /auth/confirm-email-change. + /// MS-5 decision: AllowAnonymous — the change-email token itself binds the user + /// (id + security stamp + new-email purpose) and is single-use. Forcing auth on a + /// link clicked from email is poor UX and adds no security: a stolen token alone + /// is sufficient to confirm regardless of session state. + /// + public static RouteHandlerBuilder MapConfirmEmailChangeEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/confirm-email-change", async Task> ( + [FromBody] ConfirmEmailChangeRequest request, + [FromServices] IConfirmEmailChangeHandler handler, + [FromServices] IValidator validator, + HttpContext context) => + { + if (ValidationHelper.Validate(request, validator) is { } validationErrors) + { + return TypedResults.ValidationProblem(validationErrors); + } + + // Decode Base64URL-encoded token (matches /auth/confirm-email and /auth/reset-password). + string decodedToken; + try + { + decodedToken = Base64Service.DecodeBase64UrlToken(request.Token); + } + catch (FormatException) + { + return TypedResults.BadRequest(); + } + + var decodedRequest = request with { Token = decodedToken }; + var result = await handler.HandleAsync(decodedRequest, cancellationToken: context.RequestAborted); + + if (result.IsError) + { + return result.FirstError.Type switch + { + ErrorType.Validation => TypedResults.BadRequest(), + ErrorType.Failure => TypedResults.BadRequest(), + ErrorType.NotFound => TypedResults.BadRequest(), + _ => TypedResults.InternalServerError(), + }; + } + + return TypedResults.Ok(); + }) + .WithSummary("Confirm email change") + .WithDescription("Confirms an out-of-band email change previously staged via PUT /manage/info.") + .AllowAnonymous(); + } +} diff --git a/Idmt.Plugin/Features/Auth/DiscoverTenants.cs b/Idmt.Plugin/Features/Auth/DiscoverTenants.cs index 90bc8e0..9571dc2 100644 --- a/Idmt.Plugin/Features/Auth/DiscoverTenants.cs +++ b/Idmt.Plugin/Features/Auth/DiscoverTenants.cs @@ -44,25 +44,19 @@ public async Task> HandleAsync( var normalizedEmail = request.Email.ToUpperInvariant(); var now = timeProvider.GetUtcNow(); - // Find all tenant IDs where the user has a direct account. - // IgnoreQueryFilters bypasses Finbuckle's automatic tenant filter - // so we can search across all tenants. - var directTenantIds = await dbContext.Users - .IgnoreQueryFilters() - .Where(u => u.NormalizedEmail == normalizedEmail && u.IsActive) - .Select(u => u.TenantId) - .Distinct() - .ToListAsync(cancellationToken); - - // Find tenant IDs granted via TenantAccess (cross-tenant grants). - // First find user IDs matching the email, then look up their access grants. + // Phase 1: IdmtUser is global; tenant membership lives in TenantAccess. + // Find user IDs matching the email, then look up their (active, unexpired) access grants. var userIds = await dbContext.Users - .IgnoreQueryFilters() .Where(u => u.NormalizedEmail == normalizedEmail && u.IsActive) .Select(u => u.Id) .ToListAsync(cancellationToken); - var accessTenantIds = await dbContext.TenantAccess + if (userIds.Count == 0) + { + return new DiscoverTenantsResponse([]); + } + + var allTenantIds = await dbContext.TenantAccess .Where(ta => userIds.Contains(ta.UserId) && ta.IsActive && (ta.ExpiresAt == null || ta.ExpiresAt > now)) @@ -70,9 +64,6 @@ public async Task> HandleAsync( .Distinct() .ToListAsync(cancellationToken); - // Union all tenant IDs - var allTenantIds = directTenantIds.Union(accessTenantIds).ToList(); - if (allTenantIds.Count == 0) { return new DiscoverTenantsResponse([]); diff --git a/Idmt.Plugin/Features/Auth/Login.cs b/Idmt.Plugin/Features/Auth/Login.cs index a519796..1af2623 100644 --- a/Idmt.Plugin/Features/Auth/Login.cs +++ b/Idmt.Plugin/Features/Auth/Login.cs @@ -62,6 +62,7 @@ internal sealed class LoginHandler( UserManager userManager, SignInManager signInManager, IMultiTenantContextAccessor multiTenantContextAccessor, + ITenantAccessService tenantAccessService, IOptions idmtOptions, TimeProvider timeProvider, ILogger logger) : ILoginHandler @@ -84,8 +85,8 @@ public async Task> HandleAsync( return IdmtErrors.Tenant.Inactive; } - // Find user by email or username - // EF Core multi-tenant filtering automatically ensures user belongs to the current tenant + // Phase 1 / Step 10: IdmtUser is global. Tenant membership is enforced by an + // explicit TenantAccess gate below — uniform for SysAdmin and tenant users. IdmtUser? user = null; if (request.Email is not null) { @@ -148,6 +149,13 @@ public async Task> HandleAsync( return IdmtErrors.Auth.Unauthorized; } + // Phase 1 / Step 10: enforce TenantAccess gate after successful credential check. + // Locked decision #4: uniform — even SysAdmin requires a TenantAccess row. + if (!await tenantAccessService.CanAccessTenantAsync(user.Id, idmtTenant.Id!, cancellationToken)) + { + return IdmtErrors.Auth.Unauthorized; + } + // Direct cookie sign-in (no middleware delay) var principal = await signInManager.CreateUserPrincipalAsync(user); await signInManager.Context.SignInAsync( @@ -181,6 +189,7 @@ internal sealed class TokenLoginHandler( UserManager userManager, SignInManager signInManager, IMultiTenantContextAccessor multiTenantContextAccessor, + ITenantAccessService tenantAccessService, IOptionsMonitor bearerTokenOptions, TimeProvider timeProvider, ILogger logger) : ITokenLoginHandler @@ -203,8 +212,8 @@ public async Task> HandleAsync( return IdmtErrors.Tenant.Inactive; } - // Find user by email or username - // EF Core multi-tenant filtering automatically ensures user belongs to the current tenant + // Phase 1 / Step 10: IdmtUser is global. Tenant membership is enforced by an + // explicit TenantAccess gate below — uniform for SysAdmin and tenant users. IdmtUser? user = null; if (request.Email is not null) { @@ -267,6 +276,13 @@ public async Task> HandleAsync( return IdmtErrors.Auth.Unauthorized; } + // Phase 1 / Step 10: enforce TenantAccess gate after successful credential check. + // Locked decision #4: uniform — even SysAdmin requires a TenantAccess row. + if (!await tenantAccessService.CanAccessTenantAsync(user.Id, idmtTenant.Id!, cancellationToken)) + { + return IdmtErrors.Auth.Unauthorized; + } + // Generate tokens using BearerToken var principal = await signInManager.CreateUserPrincipalAsync(user); var bearerOptions = bearerTokenOptions.Get(IdentityConstants.BearerScheme); diff --git a/Idmt.Plugin/Features/Auth/ResetPassword.cs b/Idmt.Plugin/Features/Auth/ResetPassword.cs index 7bb7a75..237547e 100644 --- a/Idmt.Plugin/Features/Auth/ResetPassword.cs +++ b/Idmt.Plugin/Features/Auth/ResetPassword.cs @@ -11,14 +11,13 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Idmt.Plugin.Features.Auth; public static class ResetPassword { - public sealed record ResetPasswordRequest(string TenantIdentifier, string Email, string Token, string NewPassword); + public sealed record ResetPasswordRequest(string Email, string Token, string NewPassword); public interface IResetPasswordHandler { @@ -26,43 +25,37 @@ public interface IResetPasswordHandler } internal sealed class ResetPasswordHandler( - ITenantOperationService tenantOps, + UserManager userManager, ILogger logger) : IResetPasswordHandler { public async Task> HandleAsync(ResetPasswordRequest request, CancellationToken cancellationToken = default) { - return await tenantOps.ExecuteInTenantScopeAsync(request.TenantIdentifier, async provider => + try { - var userManager = provider.GetRequiredService>(); - try + var user = await userManager.FindByEmailAsync(request.Email); + if (user is null || !user.IsActive) { - var user = await userManager.FindByEmailAsync(request.Email); - if (user is null || !user.IsActive) - { - return IdmtErrors.Password.ResetFailed; - } - - var result = await userManager.ResetPasswordAsync(user, request.Token, request.NewPassword); - - if (!result.Succeeded) - { - return IdmtErrors.Password.ResetFailed; - } + return IdmtErrors.Password.ResetFailed; + } - if (!user.EmailConfirmed) - { - user.EmailConfirmed = true; - await userManager.UpdateAsync(user); - } + var result = await userManager.ResetPasswordAsync(user, request.Token, request.NewPassword); - return Result.Success; - } - catch (Exception ex) + if (!result.Succeeded) { - logger.LogError(ex, "An error occurred during password reset for {Email}", PiiMasker.MaskEmail(request.Email)); - return IdmtErrors.General.Unexpected; + return IdmtErrors.Password.ResetFailed; } - }); + + // Note: Per security audit C7, password reset proves possession of the + // current Email mailbox at token-issue time only. Do NOT mutate + // EmailConfirmed here — that flag must be set exclusively via the + // ConfirmEmail flow. + return Result.Success; + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred during password reset for {Email}", PiiMasker.MaskEmail(request.Email)); + return IdmtErrors.General.Unexpected; + } } } diff --git a/Idmt.Plugin/Features/AuthEndpoints.cs b/Idmt.Plugin/Features/AuthEndpoints.cs index d599597..521342a 100644 --- a/Idmt.Plugin/Features/AuthEndpoints.cs +++ b/Idmt.Plugin/Features/AuthEndpoints.cs @@ -38,6 +38,7 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder endpoints) auth.MapRefreshTokenEndpoint(); auth.MapConfirmEmailEndpoint(); auth.MapConfirmEmailDirectEndpoint(); + auth.MapConfirmEmailChangeEndpoint(); auth.MapResendConfirmationEmailEndpoint(); auth.MapForgotPasswordEndpoint(); auth.MapResetPasswordEndpoint(); diff --git a/Idmt.Plugin/Features/Manage/GetUserInfo.cs b/Idmt.Plugin/Features/Manage/GetUserInfo.cs index 927a241..93b82a2 100644 --- a/Idmt.Plugin/Features/Manage/GetUserInfo.cs +++ b/Idmt.Plugin/Features/Manage/GetUserInfo.cs @@ -28,7 +28,9 @@ public interface IGetUserInfoHandler Task> HandleAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default); } - internal sealed class GetUserInfoHandler(UserManager userManager, IMultiTenantStore tenantStore) : IGetUserInfoHandler + internal sealed class GetUserInfoHandler( + UserManager userManager, + IMultiTenantContextAccessor multiTenantContextAccessor) : IGetUserInfoHandler { public async Task> HandleAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default) { @@ -44,10 +46,22 @@ public async Task> HandleAsync(ClaimsPrincipal user return IdmtErrors.User.NotFound; } - var roles = (await userManager.GetRolesAsync(appUser)).OrderBy(r => r).ToList(); - if (roles.Count == 0) return IdmtErrors.User.NoRolesAssigned; + // Phase 1: per-tenant IdentityRole rows for SysAdmin/SysSupport are no longer seeded. + // Sys-level authority is carried by IdmtUser.SysRole (emitted as a Role claim by + // IdmtUserClaimsPrincipalFactory). Surface it in the Roles list so callers with sys + // authority but no per-tenant IdentityRole assignment do not appear "role-less". + var perTenantRoles = await userManager.GetRolesAsync(appUser); + var rolesSet = new SortedSet(perTenantRoles, StringComparer.Ordinal); + if (appUser.SysRole != SysRoleKind.None) + { + rolesSet.Add(appUser.SysRole.ToString()); + } + if (rolesSet.Count == 0) return IdmtErrors.User.NoRolesAssigned; + var roles = rolesSet.ToList(); - var tenant = await tenantStore.GetAsync(appUser.TenantId); + // Phase 1: tenant is sourced from ambient context — IdmtUser is global and no + // longer carries TenantId. + var tenant = multiTenantContextAccessor.MultiTenantContext?.TenantInfo; if (tenant is null) return IdmtErrors.Tenant.NotFound; return new GetUserInfoResponse( @@ -56,7 +70,7 @@ public async Task> HandleAsync(ClaimsPrincipal user appUser.UserName ?? string.Empty, roles, tenant.Identifier ?? string.Empty, - tenant.Name ?? string.Empty + tenant.Name ?? tenant.Identifier ?? string.Empty ); } } diff --git a/Idmt.Plugin/Features/Manage/RegisterUser.cs b/Idmt.Plugin/Features/Manage/RegisterUser.cs index d6fc9a8..0f0f188 100644 --- a/Idmt.Plugin/Features/Manage/RegisterUser.cs +++ b/Idmt.Plugin/Features/Manage/RegisterUser.cs @@ -70,7 +70,6 @@ public async Task> HandleAsync( Email = request.Email, EmailConfirmed = false, IsActive = true, - TenantId = tenantId, LastLoginAt = null, }; @@ -101,6 +100,18 @@ public async Task> HandleAsync( return IdmtErrors.User.CreationFailed; } + // Phase 1 / Step 10: registration is intrinsically tenant-scoped — newly registered + // users must be granted explicit TenantAccess for the current tenant so that the + // uniform login gate (TenantAccessService.CanAccessTenantAsync) admits them. + dbContext.TenantAccess.Add(new TenantAccess + { + UserId = user.Id, + TenantId = tenantId, + IsActive = true, + ExpiresAt = null, + }); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); } catch (Exception ex) diff --git a/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs b/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs index ed1aeaf..ee358e4 100644 --- a/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs +++ b/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Idmt.Plugin.Features.Manage; @@ -25,9 +26,16 @@ public sealed record UpdateUserInfoRequest( string? NewPassword = null ); - public interface IUpdateUserInfoHandler + /// + /// Internal handler result. Never serialized to clients — both 200 (no email change) and 202 + /// (email change staged) responses have empty bodies. Carries only the signal needed by the + /// endpoint mapping to choose the response status. + /// + internal sealed record UpdateUserInfoResult(bool EmailChangePending); + + internal interface IUpdateUserInfoHandler { - Task> HandleAsync(UpdateUserInfoRequest request, ClaimsPrincipal user, CancellationToken cancellationToken = default); + Task> HandleAsync(UpdateUserInfoRequest request, ClaimsPrincipal user, CancellationToken cancellationToken = default); } internal sealed class UpdateUserInfoHandler( @@ -39,7 +47,7 @@ internal sealed class UpdateUserInfoHandler( ITokenRevocationService tokenRevocationService, ILogger logger) : IUpdateUserInfoHandler { - public async Task> HandleAsync( + public async Task> HandleAsync( UpdateUserInfoRequest request, ClaimsPrincipal user, CancellationToken cancellationToken = default) @@ -63,9 +71,13 @@ public async Task> HandleAsync( await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); try { - bool hasChanges = false; + bool emailChangeRequested = + !string.IsNullOrWhiteSpace(request.NewEmail) && + !string.Equals(request.NewEmail, appUser.Email, StringComparison.OrdinalIgnoreCase); + bool usernameChanged = false; + bool passwordChanged = false; - // Update username if provided + // 1. Apply username change first. SetUserNameAsync rotates SecurityStamp. if (!string.IsNullOrWhiteSpace(request.NewUsername) && request.NewUsername != appUser.UserName) { var setUsernameResult = await userManager.SetUserNameAsync(appUser, request.NewUsername); @@ -75,73 +87,69 @@ public async Task> HandleAsync( await transaction.RollbackAsync(cancellationToken); return IdmtErrors.User.UpdateFailed; } - hasChanges = true; + usernameChanged = true; } - // Update email if provided. - // ChangeEmailAsync persists the new email and sets EmailConfirmed = false internally. - // After that we send a confirmation email to the new address so the user has a - // recovery path and is not permanently locked out. - if (!string.IsNullOrWhiteSpace(request.NewEmail) && request.NewEmail != appUser.Email) + // 2. Apply password change. ChangePasswordAsync rotates SecurityStamp. + if (!string.IsNullOrWhiteSpace(request.OldPassword) && !string.IsNullOrWhiteSpace(request.NewPassword)) { - var token = await userManager.GenerateChangeEmailTokenAsync(appUser, request.NewEmail); - var changeEmailResult = await userManager.ChangeEmailAsync(appUser, request.NewEmail, token); - if (!changeEmailResult.Succeeded) + var changePasswordResult = await userManager.ChangePasswordAsync(appUser, request.OldPassword, request.NewPassword); + if (!changePasswordResult.Succeeded) { - logger.LogError("Failed to change email: {ErrorMessage}", changeEmailResult.Errors.Select(e => e.Description)); + logger.LogError("Failed to change password: {ErrorMessage}", changePasswordResult.Errors.Select(e => e.Description)); await transaction.RollbackAsync(cancellationToken); - return IdmtErrors.User.UpdateFailed; + return IdmtErrors.Password.ResetFailed; } + passwordChanged = true; + } - // ChangeEmailAsync already persisted EmailConfirmed = false — do NOT set it again - // or call UpdateAsync for the email change; doing so is redundant and can cause - // a second write with a stale concurrency stamp. + // 3. Stage email change (out-of-band confirmation). + // + // CRITICAL invariant 5a (CD-1): GenerateChangeEmailTokenAsync must be called AFTER all + // other stamp-rotating mutations (SetUserNameAsync / ChangePasswordAsync). The token is + // bound to (user.Id + SecurityStamp + "ChangeEmail:newEmail" purpose). If we generated + // it earlier, ChangePasswordAsync / SetUserNameAsync would rotate the stamp and the + // token would fail validation at confirm time. We flush + reload here to ensure the + // token is generated against the post-rotation stamp persisted in the database. + if (emailChangeRequested) + { + await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.Entry(appUser).ReloadAsync(cancellationToken); - // Generate a fresh confirmation token (the change-email token above is now consumed) - // and send the link to the new address so the user can re-confirm. - var confirmToken = await userManager.GenerateEmailConfirmationTokenAsync(appUser); - var confirmLink = linkGenerator.GenerateConfirmEmailLink(request.NewEmail, confirmToken); - await emailSender.SendConfirmationLinkAsync(appUser, request.NewEmail, confirmLink); + var token = await userManager.GenerateChangeEmailTokenAsync(appUser, request.NewEmail!); - // Revoke existing bearer tokens so old refresh tokens cannot be used - if (currentUserService.UserId is { } uid && currentUserService.TenantId is { } tid) - { - await tokenRevocationService.RevokeUserTokensAsync(uid, tid, cancellationToken); - } + // Stage the new email. Email column itself is NOT mutated — only PendingEmail. + appUser.PendingEmail = request.NewEmail; + await dbContext.SaveChangesAsync(cancellationToken); - logger.LogInformation("Email changed for user. Confirmation email dispatched to new address."); - // hasChanges intentionally not set here: ChangeEmailAsync already persisted the - // email change. The flag only controls the final UpdateAsync for other field - // changes (username, password) that are still pending. - } + var confirmLink = linkGenerator.GenerateConfirmEmailChangeLink(appUser.Email!, request.NewEmail!, token); + await emailSender.SendConfirmationLinkAsync(appUser, request.NewEmail!, confirmLink); - // Update password if provided - if (!string.IsNullOrWhiteSpace(request.OldPassword) && !string.IsNullOrWhiteSpace(request.NewPassword)) + logger.LogInformation( + "Email change staged. Confirmation link dispatched to new address. User: {UserId}.", + appUser.Id); + } + else { - var changePasswordResult = await userManager.ChangePasswordAsync(appUser, request.OldPassword, request.NewPassword); - if (!changePasswordResult.Succeeded) - { - logger.LogError("Failed to change password: {ErrorMessage}", changePasswordResult.Errors.Select(e => e.Description)); - await transaction.RollbackAsync(cancellationToken); - return IdmtErrors.Password.ResetFailed; - } - hasChanges = true; + // No email change. Persist any pending username/password mutations. + await dbContext.SaveChangesAsync(cancellationToken); } - if (hasChanges) + // Revoke existing bearer tokens ONLY when credentials (username or password) + // actually changed. Email-only requests do NOT revoke at staging time — + // Identity's ChangeEmailAsync at confirm-time rotates SecurityStamp and + // naturally invalidates sessions then. + bool credentialsChanged = passwordChanged || usernameChanged; + if (credentialsChanged + && currentUserService.UserId is { } uid + && currentUserService.TenantId is { } tid) { - var updateResult = await userManager.UpdateAsync(appUser); - if (!updateResult.Succeeded) - { - logger.LogError("Failed to update user: {Errors}", string.Join(", ", updateResult.Errors.Select(e => e.Description))); - await transaction.RollbackAsync(cancellationToken); - return IdmtErrors.User.UpdateFailed; - } + await tokenRevocationService.RevokeUserTokensAsync(uid, tid, cancellationToken); } await transaction.CommitAsync(cancellationToken); - return Result.Success; + return new UpdateUserInfoResult(EmailChangePending: emailChangeRequested); } catch (Exception ex) { @@ -154,7 +162,7 @@ public async Task> HandleAsync( public static RouteHandlerBuilder MapUpdateUserInfoEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPut("/info", async Task> ( + return endpoints.MapPut("/info", async Task> ( [FromBody] UpdateUserInfoRequest request, ClaimsPrincipal user, [FromServices] IUpdateUserInfoHandler handler, @@ -173,15 +181,22 @@ public static RouteHandlerBuilder MapUpdateUserInfoEndpoint(this IEndpointRouteB { ErrorType.NotFound => TypedResults.NotFound(), ErrorType.Forbidden => TypedResults.Forbid(), - ErrorType.Validation => TypedResults.BadRequest(), - ErrorType.Failure => TypedResults.BadRequest(), + ErrorType.Validation => TypedResults.ValidationProblem(new Dictionary { [result.FirstError.Code] = [result.FirstError.Description] }), + ErrorType.Failure => TypedResults.ValidationProblem(new Dictionary { [result.FirstError.Code] = [result.FirstError.Description] }), _ => TypedResults.InternalServerError(), }; } + + // 202 Accepted with empty body when email change is staged but not yet confirmed. + // Pointing Location header at the confirm endpoint signals the next step. + if (result.Value.EmailChangePending) + { + return TypedResults.Accepted("/auth/confirm-email-change"); + } return TypedResults.Ok(); }) .WithSummary("Update user info") - .WithDescription("Update current user authentication info") + .WithDescription("Update current user authentication info. Email changes are staged out-of-band; a confirmation link is sent to the new address and the email is committed only when the user confirms via /auth/confirm-email-change.") .RequireAuthorization(); } } diff --git a/Idmt.Plugin/Idmt.Plugin.csproj b/Idmt.Plugin/Idmt.Plugin.csproj index 8ffe614..115809b 100644 --- a/Idmt.Plugin/Idmt.Plugin.csproj +++ b/Idmt.Plugin/Idmt.Plugin.csproj @@ -6,6 +6,7 @@ enable true Idmt.Plugin + 2.0.0 iuri dotta Identity MultiTenant plugin library for ASP.NET Core README.md diff --git a/Idmt.Plugin/Migration/CanonicalIdentityDataMigrator.cs b/Idmt.Plugin/Migration/CanonicalIdentityDataMigrator.cs new file mode 100644 index 0000000..dc221fd --- /dev/null +++ b/Idmt.Plugin/Migration/CanonicalIdentityDataMigrator.cs @@ -0,0 +1,379 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.EntityFrameworkCore; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Idmt.Plugin.Migration; + +/// +/// Phase 1 canonical identity data migrator. +/// +/// +/// +/// This is a documented harness, not a production-grade migration tool. It implements +/// the data rewrites described in SECURITY_PHASE_1_CANONICAL_IDENTITY.md §"Migration for +/// existing deployments": +/// +/// +/// Group existing rows by NormalizedEmail; +/// pick canonical Id (oldest row). +/// Rewrite , IdentityUserRole.UserId, +/// , AspNetUserTokens.UserId, +/// for mutations to the canonical id. +/// Fold across duplicates (highest authority wins). +/// Drop duplicate rows. +/// Rotate on every survivor so any +/// bearer / refresh ticket minted before the migration is invalidated at first refresh. +/// +/// +/// Consumers verify the migration output against their own pre-migration schema. The plugin +/// ships the migration code; the schema snapshot itself is a consumer-side concern. +/// +/// +public sealed class CanonicalIdentityDataMigrator +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Synthetic tenant identifier injected into the DI scope while migration runs so that + /// any code path that resolves a finds a + /// non-null context. Migration itself is global. + /// + public const string MigrationTenantIdentifier = "__migration__"; + + public CanonicalIdentityDataMigrator( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + /// Inspect the existing identity data, group rows by + /// NormalizedEmail, and emit a divergence report. Does not mutate state. + /// + /// Cancellation token. + /// The dry-run report. must be supplied + /// to as proof-of-review. + public async Task DryRunAsync(CancellationToken ct = default) + { + await using var scope = CreateMigrationScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Pull all users; we group in memory so dedup logic is identical in dry-run + apply. + var users = await dbContext.Users + .AsNoTracking() + .ToListAsync(ct); + + var groups = users + .Where(u => !string.IsNullOrEmpty(u.NormalizedEmail)) + .GroupBy(u => u.NormalizedEmail!, StringComparer.Ordinal) + .ToList(); + + var duplicateGroups = groups + .Where(g => g.Count() > 1) + .Select(g => new DuplicateGroup( + NormalizedEmail: g.Key, + CanonicalUserId: PickCanonicalId(g), + DuplicateUserIds: g.Select(u => u.Id).Where(id => id != PickCanonicalId(g)).ToArray(), + FoldedSysRole: FoldSysRole(g))) + .OrderBy(g => g.NormalizedEmail, StringComparer.Ordinal) + .ToList(); + + var totalDuplicates = duplicateGroups.Sum(g => g.DuplicateUserIds.Length); + + var fingerprint = ComputeFingerprint(duplicateGroups); + + _logger.LogInformation( + "Dry-run complete. TotalUsers={TotalUsers} DuplicateGroups={DuplicateGroups} TotalDuplicateRows={TotalDuplicates} Fingerprint={Fingerprint}", + users.Count, duplicateGroups.Count, totalDuplicates, fingerprint); + + return new DryRunReport( + TotalUsers: users.Count, + DuplicateGroups: duplicateGroups, + Fingerprint: fingerprint); + } + + /// + /// Apply the canonical identity migration. Refuses to run unless + /// matches the current dry-run fingerprint. + /// + /// SHA-256 fingerprint returned by an immediately-prior call to + /// . Required to ensure operator reviewed divergence before applying. + /// Reserved for future use. Pass an empty + /// enumerable. Cross-tenant duplicate groups are accepted unconditionally in this revision. + /// Cancellation token. + /// Apply summary. + public async Task ApplyAsync( + string ackFingerprint, + IEnumerable acceptedCrossTenantMergeGroupIds, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(ackFingerprint); + ArgumentNullException.ThrowIfNull(acceptedCrossTenantMergeGroupIds); + + var dryRun = await DryRunAsync(ct); + if (!string.Equals(dryRun.Fingerprint, ackFingerprint, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Dry-run fingerprint mismatch. Re-run --dry-run and pass the new fingerprint. expected={dryRun.Fingerprint} got={ackFingerprint}"); + } + + await using var scope = CreateMigrationScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Suspend Finbuckle's tenant-mismatch check so we can write across all tenant rows + // (audit logs, IdentityUserRole, TenantAccess, ...) inside a single transaction. + var previousMode = dbContext.TenantMismatchMode; + dbContext.TenantMismatchMode = TenantMismatchMode.Ignore; + + var rewriteCounts = new RewriteCounts(); + try + { + // Wrap all mutations in an explicit transaction. ApplyGroupAsync mixes + // change-tracker writes (TenantAccess, AuditLogs, SysRole fold) with bulk + // operations (ExecuteUpdateAsync / ExecuteDeleteAsync against UserRoles, + // UserTokens, RevokedTokens, Users). Bulk operations auto-commit immediately + // and bypass the change tracker, so without an explicit transaction a failure + // in the trailing SaveChangesAsync (or anywhere mid-loop) would leave the DB + // partially migrated with no rollback path. The transaction guarantees + // all-or-nothing semantics across both write modes. + await using var transaction = await dbContext.Database.BeginTransactionAsync(ct); + try + { + foreach (var group in dryRun.DuplicateGroups) + { + rewriteCounts = await ApplyGroupAsync(dbContext, group, rewriteCounts, ct); + } + + // Rotate SecurityStamp on EVERY surviving user (canonical and unaffected). Any + // bearer / refresh ticket minted before the migration relies on the prior stamp; + // rotation forces all such tickets to fail at first refresh. F42 invariant. + var stampRotated = await RotateAllSecurityStampsAsync(dbContext, ct); + + await dbContext.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation( + "Migration applied. Groups={Groups} TenantAccessRewrites={TA} AuditRewrites={Audit} RoleRewrites={Roles} TokenRewrites={UT} StampRotations={Stamps} DuplicatesDeleted={Deletes}", + dryRun.DuplicateGroups.Count, rewriteCounts.TenantAccess, rewriteCounts.AuditLogs, + rewriteCounts.IdentityUserRoles, rewriteCounts.IdentityUserTokens, stampRotated, rewriteCounts.DuplicatesDeleted); + + return new ApplyReport( + GroupsProcessed: dryRun.DuplicateGroups.Count, + Rewrites: rewriteCounts, + StampsRotated: stampRotated); + } + catch + { + await transaction.RollbackAsync(ct); + throw; + } + } + finally + { + dbContext.TenantMismatchMode = previousMode; + } + } + + private async Task ApplyGroupAsync( + IdmtDbContext dbContext, + DuplicateGroup group, + RewriteCounts counts, + CancellationToken ct) + { + var canonicalId = group.CanonicalUserId; + var duplicates = group.DuplicateUserIds; + + // Step 1: rewrite TenantAccess rows that point at any duplicate. + var taRows = await dbContext.TenantAccess + .Where(ta => duplicates.Contains(ta.UserId)) + .ToListAsync(ct); + foreach (var row in taRows) + { + row.UserId = canonicalId; + } + counts = counts with { TenantAccess = counts.TenantAccess + taRows.Count }; + + // Step 2: rewrite IdmtAuditLog rows. + var auditRows = await dbContext.AuditLogs + .Where(a => a.UserId.HasValue && duplicates.Contains(a.UserId.Value)) + .ToListAsync(ct); + foreach (var row in auditRows) + { + row.UserId = canonicalId; + } + counts = counts with { AuditLogs = counts.AuditLogs + auditRows.Count }; + + // Step 3: rewrite IdentityUserRole rows. Use raw connection because the navigation + // property graph of MultiTenantIdentityDbContext makes EF tracking awkward across + // composite-key entities. ExecuteUpdate keeps it provider-agnostic. + var userRoleRewrites = 0; + foreach (var dup in duplicates) + { + var dupCopy = dup; + var canonicalCopy = canonicalId; + userRoleRewrites += await dbContext.UserRoles + .Where(ur => ur.UserId == dupCopy) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.UserId, _ => canonicalCopy), ct); + } + counts = counts with { IdentityUserRoles = counts.IdentityUserRoles + userRoleRewrites }; + + // Step 4: rewrite IdentityUserToken rows. + var userTokenRewrites = 0; + foreach (var dup in duplicates) + { + var dupCopy = dup; + var canonicalCopy = canonicalId; + userTokenRewrites += await dbContext.UserTokens + .Where(ut => ut.UserId == dupCopy) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.UserId, _ => canonicalCopy), ct); + } + counts = counts with { IdentityUserTokens = counts.IdentityUserTokens + userTokenRewrites }; + + // Note: RevokedToken keys are composite "{userId}:{tenantId}" strings (see + // TokenRevocationService.BuildTokenId). Rewriting them risks collisions with existing + // canonical-keyed rows. Migration drops legacy duplicate-keyed revocations; consumers + // must accept that pre-migration revocations on shadow-row userIds will lose their + // record. In practice the SecurityStamp rotation (Step 6) invalidates all pre-migration + // refresh tickets anyway, which is the security-critical invariant. + var legacyRevocationDeletes = 0; + foreach (var dup in duplicates) + { + var prefix = $"{dup}:"; + var prefixCopy = prefix; + legacyRevocationDeletes += await dbContext.RevokedTokens + .Where(rt => rt.TokenId.StartsWith(prefixCopy)) + .ExecuteDeleteAsync(ct); + } + counts = counts with { LegacyRevocationsDeleted = counts.LegacyRevocationsDeleted + legacyRevocationDeletes }; + + // Step 5: fold SysRole onto canonical row. + var canonical = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == canonicalId, ct); + if (canonical is not null) + { + if ((int)canonical.SysRole < (int)group.FoldedSysRole) + { + canonical.SysRole = group.FoldedSysRole; + } + } + + // Step 6: drop duplicate IdmtUser rows. + var deleted = await dbContext.Users + .Where(u => duplicates.Contains(u.Id)) + .ExecuteDeleteAsync(ct); + counts = counts with { DuplicatesDeleted = counts.DuplicatesDeleted + deleted }; + + return counts; + } + + private static async Task RotateAllSecurityStampsAsync(IdmtDbContext dbContext, CancellationToken ct) + { + // Generate a deterministic-looking but unique-per-row stamp. Identity treats SecurityStamp + // opaquely, so any change invalidates downstream tickets validated via + // SignInManager.ValidateSecurityStampAsync. + var users = await dbContext.Users.ToListAsync(ct); + foreach (var user in users) + { + user.SecurityStamp = Guid.NewGuid().ToString("N"); + } + return users.Count; + } + + private static Guid PickCanonicalId(IEnumerable group) + { + // Guid.CreateVersion7 (used by IdmtUser) is monotonic by creation time, so the + // smallest value is the oldest row. Falls back to Guid.CompareTo for non-v7 ids. + return group.Min(u => u.Id); + } + + private static SysRoleKind FoldSysRole(IEnumerable group) + { + // Highest authority wins. SysAdmin > SysSupport > None. + var max = group.Max(u => (int)u.SysRole); + return (SysRoleKind)max; + } + + private static string ComputeFingerprint(IReadOnlyList groups) + { + // Stable, deterministic hash of the dry-run output so the operator must re-acknowledge + // if the data drifts between dry-run and apply. + var sb = new StringBuilder(); + foreach (var group in groups.OrderBy(g => g.NormalizedEmail, StringComparer.Ordinal)) + { + sb.Append(group.NormalizedEmail).Append('|'); + sb.Append(group.CanonicalUserId.ToString("N", CultureInfo.InvariantCulture)).Append('|'); + sb.Append(((int)group.FoldedSysRole).ToString(CultureInfo.InvariantCulture)).Append('|'); + foreach (var dup in group.DuplicateUserIds.OrderBy(g => g)) + { + sb.Append(dup.ToString("N", CultureInfo.InvariantCulture)).Append(','); + } + sb.Append(';'); + } + Span hash = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()), hash); + return Convert.ToHexStringLower(hash); + } + + private AsyncServiceScope CreateMigrationScope() + { + var scope = _serviceProvider.CreateAsyncScope(); + // Inject a synthetic multi-tenant context. Some downstream services (e.g. EF query + // filter providers) read the context during DbContext construction. + var setter = scope.ServiceProvider.GetService(); + if (setter is not null) + { + var sentinel = new IdmtTenantInfo( + id: MigrationTenantIdentifier, + identifier: MigrationTenantIdentifier, + name: "Migration Sentinel"); + setter.MultiTenantContext = new MultiTenantContext(sentinel); + } + return scope; + } + + /// + /// Result of . + /// + public sealed record DryRunReport( + int TotalUsers, + IReadOnlyList DuplicateGroups, + string Fingerprint); + + /// + /// Per-group divergence record. + /// + public sealed record DuplicateGroup( + string NormalizedEmail, + Guid CanonicalUserId, + Guid[] DuplicateUserIds, + SysRoleKind FoldedSysRole); + + /// + /// Result of . + /// + public sealed record ApplyReport( + int GroupsProcessed, + RewriteCounts Rewrites, + int StampsRotated); + + /// + /// Counts of rows mutated during apply. + /// + public sealed record RewriteCounts( + int TenantAccess = 0, + int AuditLogs = 0, + int IdentityUserRoles = 0, + int IdentityUserTokens = 0, + int LegacyRevocationsDeleted = 0, + int DuplicatesDeleted = 0); +} diff --git a/Idmt.Plugin/Migration/MigrationCurrentUserService.cs b/Idmt.Plugin/Migration/MigrationCurrentUserService.cs new file mode 100644 index 0000000..a19d2f9 --- /dev/null +++ b/Idmt.Plugin/Migration/MigrationCurrentUserService.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using Idmt.Plugin.Services; + +namespace Idmt.Plugin.Migration; + +/// +/// stub used during the canonical identity data +/// migration. The migration runs in an offline harness without an HTTP context and +/// without an authenticated principal; this implementation lets +/// emit audit rows during SaveChangesAsync without throwing a NRE. +/// +/// +/// All identity-bearing properties return / . +/// Audit rows written under this service therefore carry a UserId = null, which is +/// the intended sentinel for system-driven mutations during migration. +/// +internal sealed class MigrationCurrentUserService : ICurrentUserService +{ + public ClaimsPrincipal? User => null; + + public string? IpAddress => null; + + public string? UserAgent => null; + + public Guid? UserId => null; + + public string? UserIdAsString => null; + + public string? Email => null; + + public string? UserName => null; + + public string? TenantId => null; + + public string? TenantIdentifier => null; + + public bool IsActive => false; + + public bool IsInRole(string role) => false; + + void ICurrentUserService.SetCurrentUser(ClaimsPrincipal? user, string? ipAddress, string? userAgent) + { + // No-op: migration harness has no caller principal to track. + } +} diff --git a/Idmt.Plugin/Migration/MigrationServiceCollectionExtensions.cs b/Idmt.Plugin/Migration/MigrationServiceCollectionExtensions.cs new file mode 100644 index 0000000..bc40356 --- /dev/null +++ b/Idmt.Plugin/Migration/MigrationServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Idmt.Plugin.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Idmt.Plugin.Migration; + +/// +/// Public DI helpers for the canonical identity migration harness. +/// +public static class MigrationServiceCollectionExtensions +{ + /// + /// Wires the migration tooling into the supplied service collection. Replaces the live + /// scoped registration (which expects an HTTP context) + /// with the migration stub so audit emission + /// during SaveChangesAsync does not throw. + /// + /// + /// Call this after AddIdmt<TDbContext>(). The CLI host + /// Idmt.Migrator is the primary consumer; library users running offline migrations + /// from custom hosts may also use it. + /// + public static IServiceCollection AddIdmtMigration(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.RemoveAll(); + services.AddScoped(); + + services.AddSingleton(); + + return services; + } +} diff --git a/Idmt.Plugin/Models/IdmtRole.cs b/Idmt.Plugin/Models/IdmtRole.cs index 1d9a5ac..b0f08f9 100644 --- a/Idmt.Plugin/Models/IdmtRole.cs +++ b/Idmt.Plugin/Models/IdmtRole.cs @@ -25,9 +25,14 @@ public static class IdmtDefaultRoleTypes public const string SysSupport = "SysSupport"; public const string TenantAdmin = "TenantAdmin"; // The only non sys role that can create users + /// + /// Default per-tenant roles seeded into every new tenant. + /// Phase 1: SysAdmin/SysSupport are NO LONGER seeded as per-tenant rows. + /// Sys-level authority is sourced from + ambient TenantAccess gate. + /// The and string constants remain for policy + /// RequireRole(...) matches against the -emitted role claim. + /// public static string[] DefaultRoles => [ - SysAdmin, - SysSupport, TenantAdmin ]; } \ No newline at end of file diff --git a/Idmt.Plugin/Models/IdmtUser.cs b/Idmt.Plugin/Models/IdmtUser.cs index 3ae6811..fafc6ef 100644 --- a/Idmt.Plugin/Models/IdmtUser.cs +++ b/Idmt.Plugin/Models/IdmtUser.cs @@ -15,9 +15,14 @@ public class IdmtUser : IdentityUser, IAuditable public override string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); /// - /// The tenant this user belongs to. + /// System-level role assignment for this user. Defaults to . /// - public string TenantId { get; set; } = null!; + public SysRoleKind SysRole { get; set; } = SysRoleKind.None; + + /// + /// Email address staged for an out-of-band confirmation email change. Null when no change pending. + /// + public string? PendingEmail { get; set; } /// /// Soft delete flag - inactive users are considered deleted. @@ -33,5 +38,9 @@ public class IdmtUser : IdentityUser, IAuditable public string GetName() => nameof(IdmtUser); - public string? GetTenantId() => TenantId; + /// + /// Returns null because is a global entity post Phase 1 + /// (canonical identity migration). Audit rows for IdmtUser mutations carry no TenantId. + /// + public string? GetTenantId() => null; } \ No newline at end of file diff --git a/Idmt.Plugin/Models/SysRoleKind.cs b/Idmt.Plugin/Models/SysRoleKind.cs new file mode 100644 index 0000000..f352f1f --- /dev/null +++ b/Idmt.Plugin/Models/SysRoleKind.cs @@ -0,0 +1,8 @@ +namespace Idmt.Plugin.Models; + +public enum SysRoleKind +{ + None = 0, + SysAdmin = 1, + SysSupport = 2, +} diff --git a/Idmt.Plugin/Persistence/IdmtDbContext.cs b/Idmt.Plugin/Persistence/IdmtDbContext.cs index 3d6d6e1..be15ae3 100644 --- a/Idmt.Plugin/Persistence/IdmtDbContext.cs +++ b/Idmt.Plugin/Persistence/IdmtDbContext.cs @@ -86,12 +86,49 @@ protected override void OnModelCreating(ModelBuilder builder) dto => dto == null ? null : dto.Value.UtcTicks, ticks => ticks == null ? null : new DateTimeOffset(ticks.Value, TimeSpan.Zero)); - // Configure user entity with proper multi-tenant support + // Phase 1: IdmtUser is a global entity (no per-tenant filter). Email is globally unique. + // The Finbuckle MultiTenantIdentityDbContext base implementation stamps every Identity + // entity (including IdmtUser) as multi-tenant during base.OnModelCreating. We undo that + // here on IdmtUser only so that: + // 1. There is no shadow TenantId column, + // 2. The legacy (NormalizedUserName, TenantId) unique index is dropped, + // 3. Finbuckle's tenant query filter is not applied to IdmtUser. builder.Entity(entity => { + // Drop Finbuckle's auto-stamped multi-tenant annotation on IdmtUser. + entity.Metadata.RemoveAnnotation("Finbuckle:MultiTenant"); + + // Drop any indexes that referenced the shadow TenantId property — must be done + // before the property itself is removed, otherwise EF Core throws. + var legacyIndexes = entity.Metadata.GetIndexes() + .Where(ix => ix.Properties.Any(p => string.Equals(p.Name, "TenantId", StringComparison.Ordinal))) + .ToList(); + foreach (var ix in legacyIndexes) + { + entity.Metadata.RemoveIndex(ix); + } + + // Drop the shadow TenantId property added by Finbuckle. + var tenantIdProperty = entity.Metadata.FindProperty("TenantId"); + if (tenantIdProperty is not null) + { + entity.Metadata.RemoveProperty(tenantIdProperty); + } + + // Clear any tenant-scoped query filter(s) that Finbuckle injected for IdmtUser. + // EF Core 10 supports multiple named query filters; clear them all by name plus + // the legacy unnamed filter. + foreach (var filter in entity.Metadata.GetDeclaredQueryFilters().ToList()) + { + if (filter.Key is { } key) + { + entity.Metadata.SetQueryFilter(key, null); + } + } + entity.Metadata.SetQueryFilter(null); + entity.HasIndex(u => u.IsActive); - entity.HasIndex(u => new { u.Email, u.UserName, u.TenantId }).IsUnique(); - entity.IsMultiTenant(); + entity.HasIndex(u => u.NormalizedEmail).IsUnique(); entity.Property(u => u.LastLoginAt).HasConversion(nullableDateTimeOffsetConverter); }); diff --git a/Idmt.Plugin/Services/ITenantOperationService.cs b/Idmt.Plugin/Services/ITenantOperationService.cs index 26d4375..a3b69c5 100644 --- a/Idmt.Plugin/Services/ITenantOperationService.cs +++ b/Idmt.Plugin/Services/ITenantOperationService.cs @@ -4,11 +4,24 @@ namespace Idmt.Plugin.Services; public interface ITenantOperationService { + /// + /// Runs inside a child DI scope with the Finbuckle ambient tenant + /// context set to . The previous ambient tenant context is + /// restored when the returned Task completes — including when + /// throws. + /// + /// + /// The delegate must not leak unawaited work (for example, fire-and-forget Task.Run) + /// that continues past its returned Task. The ambient tenant context is restored on Task + /// completion, so any continuation running afterward observes the restored (not the target) + /// context. Honor this invariant by awaiting every Task the delegate starts before it returns. + /// Task> ExecuteInTenantScopeAsync( string tenantIdentifier, Func>> operation, bool requireActive = true); + /// Task> ExecuteInTenantScopeAsync( string tenantIdentifier, Func>> operation, diff --git a/Idmt.Plugin/Services/IdmtLinkGenerator.cs b/Idmt.Plugin/Services/IdmtLinkGenerator.cs index 411bc01..1760a4b 100644 --- a/Idmt.Plugin/Services/IdmtLinkGenerator.cs +++ b/Idmt.Plugin/Services/IdmtLinkGenerator.cs @@ -13,6 +13,14 @@ public interface IIdmtLinkGenerator { string GenerateConfirmEmailLink(string email, string token); string GeneratePasswordResetLink(string email, string token); + + /// + /// Generates a confirm-email-change link to be sent to the staged new email address. + /// Links to the client form at with + /// query parameters: email (current), newEmail (staged), and token (Base64URL-encoded). + /// Per locked decision (Phase 1, Step 7): tenantIdentifier is intentionally NOT embedded. + /// + string GenerateConfirmEmailChangeLink(string currentEmail, string newEmail, string token); } public sealed class IdmtLinkGenerator( @@ -34,14 +42,17 @@ public string GenerateConfirmEmailLink(string email, string token) string url; if (mode == EmailConfirmationMode.ServerConfirm) { + // Locked decision (Phase 1, Step 8): tenantIdentifier intentionally NOT embedded + // as a query parameter. Tenant routing relies on path/host strategy or claim-based + // resolution, not URL query params. var routeValues = new RouteValueDictionary { - ["tenantIdentifier"] = tenantIdentifier, ["email"] = email, ["token"] = encodedToken, }; - // Add route strategy parameter if route strategy is active + // Add route strategy parameter if route strategy is active (path-based tenant + // routing, e.g., /{tenant}/confirm-email). This is the route segment, NOT a query. AddTenantRouteParameter(routeValues, tenantIdentifier); url = linkGenerator.GetUriByName(httpContext, IdmtEndpointNames.ConfirmEmailDirect, routeValues) @@ -52,7 +63,6 @@ public string GenerateConfirmEmailLink(string email, string token) url = BuildClientFormUrl( options.Value.Application.ClientUrl, options.Value.Application.ConfirmEmailFormPath, - tenantIdentifier, email, encodedToken); } @@ -64,6 +74,43 @@ public string GenerateConfirmEmailLink(string email, string token) return url; } + public string GenerateConfirmEmailChangeLink(string currentEmail, string newEmail, string token) + { + if (httpContextAccessor.HttpContext is null) + { + throw new InvalidOperationException("No HTTP context was found."); + } + + if (string.IsNullOrEmpty(options.Value.Application.ClientUrl)) + { + throw new InvalidOperationException("Client URL is not configured."); + } + + var encodedToken = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); + + // Locked decision (Phase 1, Step 7): no tenantIdentifier in URL. + var queryParams = new Dictionary + { + ["email"] = currentEmail, + ["newEmail"] = newEmail, + ["token"] = encodedToken, + }; + + var clientUrl = options.Value.Application.ClientUrl!; + var formPath = options.Value.Application.ConfirmEmailChangeFormPath; + var url = QueryHelpers.AddQueryString( + $"{clientUrl.TrimEnd('/')}/{formPath.TrimStart('/')}", + queryParams); + + logger.LogInformation( + "Confirm email change link generated. Current: {CurrentEmail}. New: {NewEmail}. Tenant: {TenantId}.", + PiiMasker.MaskEmail(currentEmail), + PiiMasker.MaskEmail(newEmail), + multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id ?? string.Empty); + + return url; + } + public string GeneratePasswordResetLink(string email, string token) { if (httpContextAccessor.HttpContext is null) @@ -72,12 +119,11 @@ public string GeneratePasswordResetLink(string email, string token) } var encodedToken = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); - var tenantIdentifier = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Identifier ?? string.Empty; + // Locked decision (Phase 1, Step 8): no tenantIdentifier in URL query params. var url = BuildClientFormUrl( options.Value.Application.ClientUrl, options.Value.Application.ResetPasswordFormPath, - tenantIdentifier, email, encodedToken); @@ -88,16 +134,16 @@ public string GeneratePasswordResetLink(string email, string token) return url; } - private static string BuildClientFormUrl(string? clientUrl, string formPath, string tenantIdentifier, string email, string encodedToken) + private static string BuildClientFormUrl(string? clientUrl, string formPath, string email, string encodedToken) { if (string.IsNullOrEmpty(clientUrl)) { throw new InvalidOperationException("Client URL is not configured."); } + // Locked decision (Phase 1, Step 8): no tenantIdentifier in URL query params. var queryParams = new Dictionary { - ["tenantIdentifier"] = tenantIdentifier, ["email"] = email, ["token"] = encodedToken, }; @@ -112,7 +158,12 @@ private void AddTenantRouteParameter(RouteValueDictionary routeValues, string te var routeParam = options.Value.MultiTenant.StrategyOptions .GetValueOrDefault(IdmtMultiTenantStrategy.Route, IdmtMultiTenantStrategy.DefaultRouteParameter); - // Only add if different from "tenantIdentifier" to avoid duplication + // Locked decision (Phase 1, Step 8): tenantIdentifier must NOT surface as a query param. + // The configured route-strategy param ("tenantIdentifier" by default) would become a query + // string when the endpoint has no matching {tenantIdentifier} route token — so skip it. + // Custom route-strategy names (e.g., "tenant") are populated; if the endpoint declares a + // matching route token they fill the path segment, otherwise they become a benign + // non-tenantIdentifier query param. if (!string.Equals(routeParam, "tenantIdentifier", StringComparison.Ordinal)) { routeValues[routeParam] = tenantIdentifier; diff --git a/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs b/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs index 3ae5711..1133fb1 100644 --- a/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs +++ b/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs @@ -13,7 +13,7 @@ internal sealed class IdmtUserClaimsPrincipalFactory( UserManager userManager, RoleManager roleManager, IOptions optionsAccessor, - IMultiTenantStore tenantStore, + IMultiTenantContextAccessor multiTenantContextAccessor, IOptions idmtOptions, ILogger logger) : UserClaimsPrincipalFactory(userManager, roleManager, optionsAccessor) @@ -22,22 +22,38 @@ protected override async Task GenerateClaimsAsync(IdmtUser user) { var identity = await base.GenerateClaimsAsync(user); - // Add custom claims + // Fail-closed (CD-4): principal generation requires an ambient tenant context. + // Without it we cannot emit the tenant claim — refuse to issue a principal at all + // rather than silently dropping the claim. + var tenantInfo = multiTenantContextAccessor.MultiTenantContext?.TenantInfo; + if (tenantInfo is null) + { + throw new InvalidOperationException( + "IdmtUserClaimsPrincipalFactory invoked without ambient tenant context. " + + "Ensure tenant resolver runs in middleware before authentication."); + } + + // Add IsActive claim identity.AddClaim(new Claim(IdmtClaimTypes.IsActive, user.IsActive.ToString())); - // Add tenant claim for multi-tenant strategies (header, claim, route) - // This ensures token validation includes tenant context - var claimKey = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim); + // Tenant claim is sourced from the ambient MultiTenant context (post Phase 1) — + // user.TenantId no longer exists. The strategy claim type is configurable via IdmtOptions. + var claimKey = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault( + IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim); - // Try to get tenant info from store using user's TenantId - var tenantInfo = await tenantStore.GetAsync(user.TenantId); - if (tenantInfo is null) + identity.AddClaim(new Claim(claimKey, tenantInfo.Identifier ?? string.Empty)); + + // Emit the SysRole claim only when the user has been assigned a system role. + // Enum string values ("SysAdmin"/"SysSupport") match the existing role-policy strings + // so RequireSysAdmin/RequireSysUser policies match without per-tenant role membership. + if (user.SysRole != SysRoleKind.None) { - logger.LogWarning("Tenant information not found for tenant ID: {TenantId}. User ID: {UserId}. Returning identity without tenant claim.", user.TenantId, user.Id); - return identity; + identity.AddClaim(new Claim(ClaimTypes.Role, user.SysRole.ToString())); } - identity.AddClaim(new Claim(claimKey, tenantInfo.Identifier ?? string.Empty)); + logger.LogDebug( + "Generated principal for user {UserId} with ambient tenant {TenantIdentifier}.", + user.Id, tenantInfo.Identifier); return identity; } diff --git a/Idmt.Plugin/Services/TenantOperationService.cs b/Idmt.Plugin/Services/TenantOperationService.cs index 917ae80..c7cdb25 100644 --- a/Idmt.Plugin/Services/TenantOperationService.cs +++ b/Idmt.Plugin/Services/TenantOperationService.cs @@ -13,11 +13,19 @@ public async Task> ExecuteInTenantScopeAsync( Func>> operation, bool requireActive = true) { + // Resolve accessor/setter from the outer (caller's) provider so we write to the same + // AsyncLocal-backed context the outer request reads. Capture the previous context before + // any mutation so we can restore it in finally — including when the delegate throws. + var accessor = serviceProvider.GetRequiredService(); + var setter = serviceProvider.GetRequiredService(); + var previousContext = accessor.MultiTenantContext; + + // invariant: inner-scope CurrentUserService.User intentionally null. See plan H2. using var scope = serviceProvider.CreateScope(); var provider = scope.ServiceProvider; var tenantStore = provider.GetRequiredService>(); - var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier); + var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier).ConfigureAwait(false); if (tenantInfo is null) { @@ -29,11 +37,15 @@ public async Task> ExecuteInTenantScopeAsync( return IdmtErrors.Tenant.Inactive; } - // Set tenant context before resolving scoped services - var tenantContextSetter = provider.GetRequiredService(); - tenantContextSetter.MultiTenantContext = new MultiTenantContext(tenantInfo); - - return await operation(provider); + try + { + setter.MultiTenantContext = new MultiTenantContext(tenantInfo); + return await operation(provider).ConfigureAwait(false); + } + finally + { + setter.MultiTenantContext = previousContext; + } } public async Task> ExecuteInTenantScopeAsync( @@ -41,6 +53,6 @@ public async Task> ExecuteInTenantScopeAsync( Func>> operation, bool requireActive = true) { - return await ExecuteInTenantScopeAsync(tenantIdentifier, operation, requireActive); + return await ExecuteInTenantScopeAsync(tenantIdentifier, operation, requireActive).ConfigureAwait(false); } } diff --git a/Idmt.Plugin/Validation/ConfirmEmailChangeRequestValidator.cs b/Idmt.Plugin/Validation/ConfirmEmailChangeRequestValidator.cs new file mode 100644 index 0000000..d5d9fac --- /dev/null +++ b/Idmt.Plugin/Validation/ConfirmEmailChangeRequestValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Idmt.Plugin.Features.Auth; + +namespace Idmt.Plugin.Validation; + +public class ConfirmEmailChangeRequestValidator : AbstractValidator +{ + public ConfirmEmailChangeRequestValidator() + { + RuleFor(x => x.Email).NotEmpty() + .WithMessage("Email is required") + .Must(Validators.IsValidEmail) + .WithMessage("Invalid email address"); + + RuleFor(x => x.NewEmail).NotEmpty() + .WithMessage("New email is required") + .Must(Validators.IsValidEmail) + .WithMessage("Invalid new email address"); + + RuleFor(x => x.Token).NotEmpty() + .WithMessage("Token is required"); + } +} diff --git a/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs b/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs index 63ea408..071dfcd 100644 --- a/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs +++ b/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs @@ -7,9 +7,6 @@ public class ConfirmEmailRequestValidator : AbstractValidator x.TenantIdentifier).NotEmpty() - .WithMessage("Tenant identifier is required"); - RuleFor(x => x.Email).NotEmpty() .WithMessage("Email is required") .Must(Validators.IsValidEmail) diff --git a/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs b/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs index 2f9eef7..2eae7e8 100644 --- a/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs +++ b/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs @@ -9,9 +9,6 @@ public class ResetPasswordRequestValidator : AbstractValidator options) { - RuleFor(x => x.TenantIdentifier).NotEmpty() - .WithMessage("Tenant identifier is required."); - RuleFor(x => x.Email).NotEmpty() .WithMessage("Email is required.") .Must(Validators.IsValidEmail) diff --git a/Idmt.slnx b/Idmt.slnx index 46ddcb0..78a9b37 100644 --- a/Idmt.slnx +++ b/Idmt.slnx @@ -6,5 +6,8 @@ + + + diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..895bbff --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,453 @@ +# IDMT Plugin — Security Findings & Remediation Plan (Revised) + +## Context + +Security audit of IDMT (Identity MultiTenant) ASP.NET Core plugin at `/home/iuri/code/idmt-plugin`. Three review agents (security-auditor, feature-dev:code-reviewer, architect-reviewer) produced initial findings; fourth (architect-critic) attacked consolidation. Doc reflects critic pass: findings downgraded/rejected with evidence, new blockers added, remediation order corrected, foundational architectural decision locked in. + +Scope: authentication (cookie + bearer), authorization policies, multi-tenancy isolation, identity flows (register/confirm/reset), admin CRUD, middleware ordering, token revocation, config defaults, PII logging, rate limiting, data model coherence. + +**Overall posture**: solid fundamentals (per-tenant cookie naming, SameSite=Strict, centralized ErrorOr, options validator, scheme policy selector), but **per-tenant shadow-user data model** root of several coherence bugs (password rotation, stamp invalidation, revocation keying). Access-token revocation structurally incomplete; admin authorization conflates SysSupport with SysAdmin; ambient tenant-context mutation leaks across async boundaries. + +--- + +## Architectural decision (foundation for all other fixes) + +**Canonical `IdmtUser` + `TenantAccess` + global `SysRole` column.** + +Current code stores one `IdmtUser` row per tenant (shadow rows created by `GrantTenantAccess.cs:117-133`, copying `PasswordHash` and `LockoutEnd`). Model makes password rotation, security-stamp invalidation, lockout propagation, token revocation keying incoherent across tenants (see new blockers N1, N2). Primary use case for `GrantTenantAccess`: SysUsers hop into any tenant; secondary: normal user with multiple tenant memberships. Canonical model serves both without cloning. + +### Target schema + +``` +IdmtUser (global — drop IsMultiTenant) + Id, Email, NormalizedEmail, PasswordHash, SecurityStamp, + LockoutEnd, EmailConfirmed, IsActive, ... + SysRole : SysRoleKind // non-null enum: None | SysAdmin | SysSupport <-- NEW + // default = None (stored as int) + +IdmtRole (per-tenant, IsMultiTenant — unchanged) + Id, Name, TenantId + Populated with TenantAdmin, TenantUser, or consumer-defined roles. + (Drop pre-seeded SysAdmin/SysSupport rows; they move to IdmtUser.SysRole.) + +IdentityUserRole (per-tenant, IsMultiTenant — unchanged) + UserId -> IdmtUser.Id, RoleId -> IdmtRole.Id, TenantId (Finbuckle shadow) + +TenantAccess (per-tenant — unchanged) + UserId, TenantId, IsActive, ExpiresAt +``` + +### Flow impact + +| Flow | Before | After | +|---|---|---| +| SysUser into tenant B | `GrantTenantAccess` clones user, copies hash, compensation window | Set `IdmtUser.SysRole = SysAdmin`. No clone, no `TenantAccess` row required. Works in every tenant immediately. | +| Normal user granted role in tenant B | `TenantAccess` + `IdentityUserRole` per tenant | Unchanged. | +| Sys + tenant role combo | Clone + role assign in shadow | `SysRole` set + per-tenant `IdentityUserRole` row. Factory emits both claims. | +| Password rotation | Only updates tenant-A hash | One hash, coherent. | +| Security-stamp invalidation | Per-tenant only | One stamp, coherent. | +| Token revocation by `(userId, tenantId)` | Wrong `userId` for shadow rows | One canonical `userId`; per-tenant revoke still valid. | +| Email change | Per-tenant | One place. Document as intentional. | + +### Claim assembly (`IdmtUserClaimsPrincipalFactory.cs:26`) + +```csharp +var roles = await userManager.GetRolesAsync(user); // per-tenant (Finbuckle-filtered) — unchanged +foreach (var role in roles) + identity.AddClaim(new Claim(ClaimTypes.Role, role)); +if (user.SysRole != SysRoleKind.None) + identity.AddClaim(new Claim(ClaimTypes.Role, user.SysRole.ToString())); +``` + +### Finbuckle integration + +`IdmtUser` entity drops `.IsMultiTenant()` in `IdmtDbContext.cs`. Relocate `IdmtUser` DbSet ownership: either (a) keep in `IdmtDbContext` with no tenant filter applied to that entity only, or (b) move to `IdmtTenantStoreDbContext` (global store). Option (a) less invasive — one `modelBuilder.Entity()` adjustment — keeps Identity's UserStore resolver pointed at single context. + +`UserManager.FindByEmailAsync` / `FindByIdAsync` resolve globally. `GetRolesAsync` still filters per-tenant via `IdentityUserRole` multi-tenancy. + +### Migration (existing deployments) + +Offline script: +1. Group existing `IdmtUser` rows by `NormalizedEmail`. Pick canonical `Id` (oldest row). +2. Rewrite `TenantAccess.UserId`, `IdentityUserRole.UserId`, `RevokedToken.UserId`, audit rows to canonical id. +3. Merge `SysRole` from any tenant row where user was `SysAdmin`/`SysSupport` → set on canonical row. All other rows default to `SysRoleKind.None`. +4. Drop duplicate `IdmtUser` rows. +5. Force password reset for all users (hashes may have diverged across shadows). + +Document as breaking change. Bump major. + +### Blast-radius note + +Canonical model means compromise of user's canonical hash grants access to every tenant they're in. Current shadow model *appears* to bound this but actually shares hash via `GrantTenantAccess.cs:117-133`, so canonical strictly better: rotation and stamp invalidation now actually work. Document explicitly. + +--- + +## Critical + +### C1. Access tokens never checked against revocation store +- Files: `Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs`, `Idmt.Plugin/Services/TokenRevocationService.cs`, `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:370-384` (`BearerTokenEvents`) +- `IsTokenRevokedAsync` called only inside `RefreshToken.HandleAsync:74`. No middleware, auth event, or policy checks revocation for plain access-token requests. After logout/password-reset/revoke-tenant-access, previously-issued bearer access token keeps working until expiration. +- Attack: stolen bearer survives logout/password reset up to `BearerTokenExpiration` (60 min default). +- Fix: wire `BearerTokenEvents.OnTokenValidated` to read principal `NameIdentifier` + tenant + ticket `IssuedUtc` and call `IsTokenRevokedAsync`; fail ticket on hit. Cache recent revocations with short TTL (~30 s) to bound DB load. Middleware alternative acceptable but must run between `UseAuthentication` and `UseAuthorization`. +- **Must ship together with M2 (IssuedUtc set explicitly).** C1 relies on `IssuedUtc`; if unset, revocation check fails soft. + +### C2. Admin endpoints guarded by `RequireSysUser` instead of `RequireSysAdmin` +- Files: `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:426-432`, `Idmt.Plugin/Features/AdminEndpoints.cs:14`, `Features/Admin/DeleteTenant.cs:74`, `CreateTenant.cs`, `GrantTenantAccess.cs:239`, `RevokeTenantAccess.cs:116`, `GetAllTenants.cs:91`, `GetUserTenants.cs:102` +- `RequireSysAdminPolicy` defined but never referenced. `RequireSysUserPolicy = SysAdmin OR SysSupport`. SysSupport can create/delete tenants and grant themselves tenant access. +- Attack: SysSupport → `GrantTenantAccess(userId=self, tenantIdentifier=any)` → arbitrary tenant access. Full escalation. +- Fix: tenant lifecycle (create/delete) and grant/revoke must require `RequireSysAdminPolicy`. Listing may stay on `RequireSysUser`. Add self-grant guard in `GrantTenantAccess`: reject when `request.UserId == currentUserService.UserId`. + +### C4. `GrantTenantAccess` copies source user's `PasswordHash` verbatim (subsumed by canonical-user migration) +- File: `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs:117-133` +- `CreateAsync(targetUser)` without password arg stores copied hash directly. Stamp regenerated but hash shared. Root cause of stamp/hash drift (see N1). +- Fix: **delete shadow-user creation branch entirely.** Under canonical model, `GrantTenantAccess` only writes `TenantAccess` row + optional `IdentityUserRole` for canonical user. No `IdmtUser` creation. + +### C7. `UpdateUserInfo` email change + `ResetPassword` auto-confirm → account takeover chain +- Files: `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs:86-104`, `Features/Auth/ResetPassword.cs:52-56` +- `UpdateUserInfo` generates own change-email token inline and immediately calls `ChangeEmailAsync` — no out-of-band confirmation of new address. Then `ResetPassword` sets `user.EmailConfirmed = true` silently after successful reset. +- Attack: attacker with temp session → `PUT /manage/info` with `NewEmail` (attacker's address) → attacker calls `ForgotPassword` on new email → resets password → `EmailConfirmed` flipped to `true`. Account rebound, no victim-side confirmation. +- Fix: change-email requires out-of-band confirmation link on new address. New address staged (not committed to `Email`) until confirmation link opened. Remove silent `EmailConfirmed = true` from `ResetPassword`. + +### N1 (new). Split-identity renders revocation and stamp rotation incoherent across tenants +- Evidence: `GrantTenantAccess.cs:117-133` produces tenant-B shadow with fresh `Id` and fresh `SecurityStamp`. `TokenRevocationService.RevokeUserTokensAsync(userId, tenantId)` at `TokenRevocationService.cs:16` stores revocations keyed on passed-in `userId`. Callers that pass tenant-A's `userId` never revoke tenant-B sessions (tenant-B shadow has different id). `RevokeTenantAccess.cs:67-80` precisely this bug: revoke by caller's `userId`, then flip `IsActive` on *different* row. +- Also: `UpdateSecurityStampAsync` mutates tenant-A row only. Sessions in tenant B survive. +- Attack: admin "revokes" user X in tenant A after suspected compromise. Attacker keeps using tenant-B bearer token for 60 min. Password rotation in A also doesn't propagate. +- Fix: resolved by canonical migration. Single `IdmtUser.Id` → one revocation key, one stamp. C4's "don't copy hash" would not fix this; C4 necessary but not sufficient. + +### N2 (new). `TenantOperationService` mutates ambient `IMultiTenantContext` without restore; outer request reads wrong tenant +- File: `Idmt.Plugin/Services/TenantOperationService.cs:33` +- Resolves `IMultiTenantContextSetter` from child scope and writes to it. `IMultiTenantContextAccessor` in Finbuckle backed by `AsyncLocal`; writes via child-scope setter mutate ambient flow. On return, outer `DbContext`, `UserManager`, `ICurrentUserService`, any audit writer see tenant B, not outer request's tenant. +- `GrantTenantAccess.cs:181` already uses compensating re-entrant call — symptom of this confusion. +- Attack vector: any handler using `ExecuteInTenantScopeAsync` mid-request then writing data after delegate lands those writes under wrong tenant. Currently no handler does this, but latent cross-tenant write-corruption bug one `git commit` away. +- Fix: capture `previous = accessor.MultiTenantContext` on entry; `try { setter.MultiTenantContext = target; await operation(provider); } finally { setter.MultiTenantContext = previous; }`. Must block C4, C7, anything else routing through service. Upgraded from H6 to Critical. + +### N3 (new). Partial-failure window in `GrantTenantAccess` +- File: `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs:106-214` +- Order of writes: tenant-B user created and committed inside `ExecuteInTenantScopeAsync` at ~line 152; outer `dbContext.SaveChangesAsync` for `TenantAccess` at line 171. Between these two points, if request cancelled or outer `SaveChanges` fails, tenant-B user already persisted and can authenticate without `TenantAccess` row (depending how tenant-access validation applied — see `TenantAccessService.cs:42-60`). Compensation (line 181+) best-effort; `LogCritical` fires if compensation throws. +- Fix: under canonical model window evaporates (no user creation — only `TenantAccess` row insert). Retain single-transaction invariant: all writes in `GrantTenantAccess` succeed or none. No compensating actions. + +--- + +## Demoted / reclassified (formerly Critical) + +### C3 → Informational gap (not exploitable as stated) +- Files: `Idmt.Plugin/Features/Auth/ConfirmEmail.cs:21,32-57`, `Features/Auth/ResetPassword.cs:21,32-66` +- Original claim: reset token from tenant A replayable against tenant B shadow user. +- Evidence against: `IdmtUser.cs:11,13` initializers give every new row fresh `Id` (Guid v7) and fresh `SecurityStamp`. Shadow row in `GrantTenantAccess.cs:117-133` is `new IdmtUser { ... }` — no `Id` or `SecurityStamp` copy. Identity's `DataProtectorTokenProvider` binds token to `userId + stamp + purpose`. Tenant-B shadow has different `Id` AND different `SecurityStamp` → token fails validation in tenant B. +- Real issue: body-supplied `TenantIdentifier` still decouples token handling from request's tenant strategy; invalidates "resolve tenant from request context" invariant and creates regression trap if anyone ever copies `Id`/`SecurityStamp`. +- Fix: remove `TenantIdentifier` from request records; resolve from context (header/claim/route). **Downgraded from Critical to hygiene gap** — ship alongside canonical migration cleanup. + +### C5 → Defensive hardening (not exploitable bypass) +- `RefreshToken.cs:62-67` already returns `Unauthorized` on null tenant before reaching revocation check at line 74. `tenantId is not null` at line 72 dead defense. +- Fix: remove dead guard; revocation check unconditional. Hygiene change only. + +### C6 → Fail-closed hygiene (narrow reach) +- `Logout.cs:69-79` silent-success branch reachable only under cookie auth with null tenant context (bearer path rejected by `ValidateBearerTokenTenantMiddleware.cs:45-54`; cookies per-tenant-named, so cookie implies tenant). Attack surface minimal — user logs out without refresh-token revocation, but they had no refresh token if they used cookie. +- Fix: return `IdmtErrors.Auth.Unauthorized` instead of 204. Never succeed logout that did not revoke. **Downgraded to Medium.** + +### H3 → Architectural smell (not exploitable) +- Current policies at `ServiceCollectionExtensions.cs:426-438` pure `RequireRole(...)`. None reads tenant-scoped services. No consumer authorization handler registered that would hit mismatched state. +- Fix: reorder middleware for correctness regardless (move `ValidateBearerTokenTenantMiddleware` + `CurrentUserMiddleware` between `UseAuthentication` and `UseAuthorization`), but impact defensive. + +--- + +## High + +### H1. `DiscoverTenants` unauthenticated + rate limiting off → enumeration oracle +- Files: `Idmt.Plugin/Features/Auth/DiscoverTenants.cs:42-88,99-122`, `Features/AuthEndpoints.cs:30-33` +- Fix: gate behind explicit `Auth.AllowTenantDiscovery` option (default false). When enabled, attach rate limiter regardless of global `RateLimiting.Enabled`. Equalize response timing AND response-length (fixed-shape placeholder payload — see N4). Consider returning only tenant IDs, not names; consider delivering list via email to address. + +### H2. Rate limiting disabled by default +- Files: `Idmt.Plugin/Configuration/IdmtOptions.cs:310`, `Features/AuthEndpoints.cs:30-33` +- Account lockout (5/5m per-user) does not cover credential stuffing across accounts, `/forgot-password` spam, `/resend-confirmation-email` spam, `/discover-tenants` enumeration. +- Fix: default `RateLimitingOptions.Enabled = true`. Apply distinct policies for `/auth/login`, `/auth/token`, `/auth/forgot-password`, `/auth/discover-tenants`, `/auth/resend-confirmation-email`. + +### H4. `ClientUrl` not validated for scheme/host → open redirect + token exfil +- Files: `Idmt.Plugin/Services/IdmtLinkGenerator.cs:91-108`, `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs:39-45` +- Fix: in validator, require `Uri.IsWellFormedUriString(url, UriKind.Absolute)` + `scheme == UriSchemeHttps` (allow `http` only via explicit `Application.AllowInsecureClientUrl = true`). Reject paths other than `/`. + +### H5. `UpdateUserInfo` transaction boundary is fake +- File: `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs:87-115` +- `UserManager.ChangeEmailAsync` calls own `SaveChangesAsync` internally; outer `BeginTransactionAsync` wrapping it does not provide atomicity. Later step failure + `RollbackAsync` leaves email change persisted. +- Fix: remove false guarantee. Serialize email change as last step after all other mutations; treat as non-atomic explicitly. + +### H7. `GrantTenantAccess` / `RevokeTenantAccess` non-normalized lookups +- Files: `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs:113,187`, `RevokeTenantAccess.cs:80` +- Under canonical migration, tenant-B-user lookup by `(Email, UserName)` goes away; any remaining case-sensitive comparisons should move to `NormalizedEmail` / `NormalizedUserName`. +- Fix: use `FindByEmailAsync` and assert identity via `Id` equality — never raw `Email == ...`. + +### H8. `ForgotPassword` hand-rolled email mask +- File: `Idmt.Plugin/Features/Auth/ForgotPassword.cs:62-64` +- Fix: replace inline masker with `PiiMasker.MaskEmail(request.Email)`. + +### N4 (new). `DiscoverTenants` response-length oracle +- File: `Idmt.Plugin/Features/Auth/DiscoverTenants.cs` +- Even with rate limiting, response shape oracle: empty array for unknown email, populated array for known. Content-Length differs. +- Fix: always return fixed-shape placeholder payload (e.g., consistent array length with deterministic masking, or opaque blob). Or deliver tenant list out-of-band via email only. + +### N5 (new). No refresh-token rotation +- File: `Idmt.Plugin/Features/Auth/RefreshToken.cs:41-81` +- Refresh call returns new access token but does not issue new refresh token nor invalidate presented one. Stolen refresh reusable for full `RefreshTokenExpiration` window. +- Fix: on refresh, issue new refresh token, revoke old one (store its `IssuedUtc` in revocation list keyed by `(userId, tenantId)` or token-id). Must precede C1 — C1 without rotation half the fix. + +### N6 (new). `ForgotPassword` no per-email throttle +- File: `Idmt.Plugin/Features/Auth/ForgotPassword.cs:42-58` +- Every unauthenticated call triggers Identity token generation + email send. No per-email throttle. Attackers flood reset mail, drown real users' reset messages. +- Fix: per-email sliding window (e.g., 1 request / 5 min / email). Separate from global endpoint rate limit. + +### N7 (new). Audit-log writes couple durability to audit correctness +- Files: `Idmt.Plugin/Persistence/IdmtDbContext.cs:159-229` +- Audits written inside same `SaveChangesAsync` transaction as business data. Malformed audit builder fails business write; L1 "swallowed on failure" then detaches all audit entries and business write proceeds with zero audit. Either way, audit correctness and business-data durability coupled in wrong direction. +- Fix: audits go to separate transaction or append-only outbox. Per-entry try/catch at build time; on failure, record `AuditEntry { Success = false, Error = ... }` rather than dropping. For security-critical tables (`IdmtUser`, `TenantAccess`, `RevokedToken`), rethrow on audit failure — do not allow business write without audit row. +- **Upgraded from L1 to High.** + +### N8 (new). Data Protection key ring unpersisted → bearer revocation incoherence after key rotation +- File: documentation gap; `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` +- Bearer tokens use Data Protection. Without persisted key ring, host restart rotates keys; `refreshTokenProtector.Unprotect` at `RefreshToken.cs:44` fails for all pre-rotation tokens → 401. Combined with M2 (missing `IssuedUtc`), fallback `issuedAt = expiresUtc - RefreshTokenExpiration` drifts after key rotation because new tokens get new `expiresUtc` baselines, making revocation checks compare inconsistent timestamps. +- Fix: require consumer call `services.AddDataProtection().PersistKeysToX(...)` — throw at startup if no key ring configured for non-development environments. Couple with M2. +- **Upgraded from L8 to High.** + +--- + +## Medium + +### M1. Login timing oracle — no dummy hash on null user +- File: `Idmt.Plugin/Features/Auth/Login.cs:89-101,207-220` +- Fix: `userManager.PasswordHasher.VerifyHashedPassword(new IdmtUser(), DummyHash, request.Password)` on null branch. Mirror in `TokenLoginHandler`. + +### M2. Refresh-token `IssuedUtc` unset → revocation check drifts +- File: `Idmt.Plugin/Features/Auth/RefreshToken.cs:70-77`, `Features/Auth/Login.cs:290-297` +- Fix: set `IssuedUtc = timeProvider.GetUtcNow()` explicitly on auth and refresh properties in `TokenLoginHandler`. **Ship with C1 and N5.** + +### M3. `is_active` claim stamped at login — not re-evaluated for active sessions +- Files: `Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs:26`, `Services/CurrentUserService.cs:34` +- Fix: in `UpdateUser`, when `IsActive` flips to false, call `userManager.UpdateSecurityStampAsync(appUser)` AND `tokenRevocationService.RevokeUserTokensAsync(...)`. Under canonical model, revocation covers all tenants in one call. + +### M4. Handler lookups by email instead of `NameIdentifier` +- Files: `Idmt.Plugin/Features/Manage/GetUserInfo.cs:35-44`, `UpdateUserInfo.cs:47-53` +- Fix: switch to `FindByIdAsync(FindFirstValue(NameIdentifier))`. Validate security stamp post-lookup. + +### M5. `UpdateUser` / `UnregisterUser` do not block self-target or peer-rank destruction +- Files: `Idmt.Plugin/Features/Manage/UpdateUser.cs:31-56`, `Manage/UnregisterUser.cs:31-56`, `Services/TenantAccessService.cs:42-60` +- Fix: reject `userId == currentUserService.UserId` on destructive actions. TenantAdmin-on-TenantAdmin in same tenant requires "danger" flag or double-sign. + +### M6. Admin endpoints rely on group-level authorization only +- File: `Idmt.Plugin/Features/Admin/CreateTenant.cs:132-159` (and `GetAllTenants`, `GetUserTenants`, `GrantTenantAccess`, `RevokeTenantAccess`) +- Fix: add explicit `.RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy)` on each endpoint for defense-in-depth. + +### M7. `ResendConfirmationEmail` enumeration via email-dispatch side-channel +- File: `Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs:39-67` +- Fix: enqueue email send asynchronously so response timing uniform. Rate-limit by default (H2). + +### M8. PII masker inconsistent — Identity error descriptions logged unmasked +- Files: `Idmt.Plugin/Services/PiiMasker.cs:11-15`, `Features/Manage/RegisterUser.cs:92,100`, `Manage/UpdateUserInfo.cs:74,91`, `Features/Admin/GrantTenantAccess.cs:196`, `Services/IdmtEmailSender.cs:11,17,23` +- Fix: log only `IdentityError.Code`, not `Description`, where inputs can echo. Route all email logging through `PiiMasker.MaskEmail`. + +### M9. Cookie `SameSite=None` silently downgraded to `Strict` +- File: `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:333-335` +- Fix: throw at startup in `IdmtOptionsValidator` with helpful message. Don't mutate. + +### M10. `IdmtEmailSender` stub registered by default +- Files: `Idmt.Plugin/Services/IdmtEmailSender.cs`, `Extensions/ServiceCollectionExtensions.cs:452` +- Fix: do not register default. Throw at startup if `IEmailSender` missing. Optional `UseStubEmailSender()` for dev. + +### M11. `IdmtTenantInfo.Identifier` not character-class validated +- Files: `Idmt.Plugin/Models/IdmtTenantInfo.cs:17-20`, `Validation/CreateTenantRequestValidator.cs`, `Services/IdmtLinkGenerator.cs:26-65` +- Fix: enforce `^[a-z0-9-]+$` in constructor and validator. + +### M12. Password-policy defaults: 8-char, no symbol +- File: `Idmt.Plugin/Configuration/IdmtOptions.cs:148-153` +- Fix: raise `RequiredLength` default to 12. Expose `MaxFailedAccessAttempts` and `DefaultLockoutTimeSpan` via `IdmtOptions` (hardcoded at `ServiceCollectionExtensions.cs:298-300`). + +### M13. 14-day sliding cookie + 60-min access token amplifies C1 +- File: `Idmt.Plugin/Configuration/IdmtOptions.cs:198-199,215` +- Fix: default `ExpireTimeSpan` to 7 days; default `BearerTokenExpiration` to 5 min. Refresh rotation (N5) + revocation check (C1) make safe. + +### N9 (new). `SameSite=Strict` not sole CSRF defense +- File: `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:328-335` +- Safari (pre-2024 builds) and extension-initiated requests bypass `SameSite=Strict` in edge cases. Plan relies on it as CSRF defense. +- Fix: add `IAntiforgery` as defense-in-depth for cookie flows, OR explicitly document plugin as "cookie auth is browser-only, same-origin" and validate `Origin`/`Referer` on state-changing cookie requests. + +### C6 (demoted). `Logout` silent success on null tenant (cookie path) +- See reclassification above. Return `IdmtErrors.Auth.Unauthorized` instead of 204. + +--- + +## Low / Informational + +### L2. No request-body size/length caps +- All request records — FluentValidation covers format, not length. +- Fix: `.MaximumLength(256)` or similar on every string input. Document Kestrel body-size limit recommendation. + +### L3. `ConfirmEmail` GET endpoint triggers state change on link-preview fetch +- File: `Idmt.Plugin/Features/Auth/ConfirmEmail.cs:104-137` +- Fix: keep `EmailConfirmationMode.ClientForm` as default. Document `ServerConfirm` risk in XML docs. + +### L4. Revoked-token cleanup 1-hour startup delay +- File: `Idmt.Plugin/Services/TokenRevocationCleanupService.cs:14-20` +- Fix: run one cleanup pass immediately then enter loop. + +### L5. `CleanupExpiredAsync` revocation check uses `<` not `<=` +- File: `Idmt.Plugin/Services/TokenRevocationService.cs:73` +- Design documented; informational only. + +### L6. `ApiPrefix` lacks validation on `CreateTenant`'s Created response +- File: `Idmt.Plugin/Features/Admin/CreateTenant.cs:155` +- Fix: validate `ApiPrefix` as relative path. + +### L7. `customizeAuthentication` / `customizeAuthorization` can regress defaults +- File: `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:404,440` +- Fix: document customizers as additive-only. Consider additive-only hooks. + +### L9. Health endpoint exposes exception +- File: `Idmt.Plugin/Features/Health/BasicHealthCheck.cs:34-39` +- Fix: scrub stack trace in production. + +### L10. CLAUDE.md mismatch: roles described as "global", code per-tenant +- File: `Idmt.Plugin/Persistence/IdmtDbContext.cs:99-102` applies `IsMultiTenant()` to `IdmtRole`. +- Fix: update CLAUDE.md to match canonical-user migration (roles still per-tenant; only `IdmtUser` becomes global). Clarify `SysRole` column global. + +--- + +## Reclassified findings + +- **C3** → downgraded to hygiene gap. +- **C5** → defensive hardening only. +- **C6** → Medium (fail-closed). +- **H3** → architectural smell; fix for correctness. +- **H6** → upgraded to N2 (Critical). +- **L1** → upgraded to N7 (High). +- **L8** → upgraded to N8 (High). + +--- + +## Missing controls (entirely absent) + +1. Access-token-level revocation enforcement (C1). +2. Refresh-token rotation (N5). +3. Anti-forgery defense-in-depth (N9). +4. Audit log integrity — same DB, no hash chain, no append-only guarantee. +5. Data Protection key ring persistence (N8). +6. CAPTCHA / proof-of-work on unauthenticated auth endpoints. +7. IP allowlist / MFA / step-up for admin endpoints. +8. Session inventory — no "list my active sessions / revoke single device" capability. +9. Individual-session revocation granularity — `RevokeUserTokensAsync` per-user, invalidates all sessions. Under canonical model now coherent, but still lacks per-device targeting. +10. Per-tenant encryption boundary — all tenants share one DB, one DP key ring. Any bug disabling multi-tenant filtering leaks everything. + +--- + +## Recommended remediation order + +Phase gates: A must ship before B. + +**Phase 0 — Foundation (blocks everything)** +1. **N2** — `TenantOperationService` try/finally context restore. Latent cross-tenant write corruption; blocks C4, C7, any handler using `ExecuteInTenantScopeAsync`. +2. **C2** — switch admin policies to `RequireSysAdmin`. Add self-grant guard in `GrantTenantAccess`. One hour; blocks privilege escalation. + +**Phase 1 — Canonical identity migration** +3. **Architectural decision implementation**: + - Drop `IsMultiTenant()` on `IdmtUser`. + - Add `SysRole` enum column to `IdmtUser`. + - Migration script for existing deployments (see architectural section). + - Update `IdmtUserClaimsPrincipalFactory` to emit `SysRole` claim. + - Force password reset for migrated users. +4. **C4 / N1 / N3** — rewrite `GrantTenantAccess` to only write `TenantAccess` + optional `IdentityUserRole` for canonical user. Delete shadow-user creation. Fix `GrantTenantAccess` / `RevokeTenantAccess` normalized lookups (H7). +5. **C7** — out-of-band email-change confirmation; remove silent `EmailConfirmed=true` from `ResetPassword`. +6. **C3 (demoted)** — drop `TenantIdentifier` from `ConfirmEmail`/`ResetPassword` bodies. + +**Phase 2 — Bearer-token coherence** +7. **M2** — set `IssuedUtc` explicitly. Prerequisite for C1 correctness. +8. **N5** — refresh-token rotation. Revoke presented refresh on use; issue fresh. +9. **C1** — wire `BearerTokenEvents.OnTokenValidated` to call `IsTokenRevokedAsync`. +10. **C5, C6** — remove dead null-tenant guards; fail closed on null-tenant logout. + +**Phase 3 — Middleware + config hardening** +11. **H3** — move `ValidateBearerTokenTenantMiddleware` + `CurrentUserMiddleware` between `UseAuthentication` and `UseAuthorization`. +12. **H4** — validate `ClientUrl` HTTPS absolute + `Path == "/"`. +13. **H2** — default `RateLimiting.Enabled = true`; distinct policies per auth endpoint. +14. **H1 / N4** — gate `DiscoverTenants`; always-on rate limiter; fixed-shape payload. +15. **N6** — per-email throttle on `ForgotPassword`. +16. **M9, M10, M11, M12, M13** — config validation, remove default email stub, validate tenant identifier, stronger defaults. +17. **N8** — require persisted DP key ring at startup. + +**Phase 4 — Hygiene** +18. **H5** — remove fake transaction boundary in `UpdateUserInfo`. +19. **H8** — `PiiMasker` in `ForgotPassword`. +20. **M1** — login timing oracle dummy hash. +21. **M3** — propagate deactivation via stamp + revocation. +22. **M4** — handler lookups by `NameIdentifier`. +23. **M5** — self-target / peer-rank guards. +24. **M6** — endpoint-level `RequireAuthorization` defense-in-depth. +25. **M7** — `ResendConfirmationEmail` async dispatch. +26. **M8** — Identity error logging sanitized. +27. **N7** — audit log separated from business-data transaction; rethrow on audit failure for security-critical tables. +28. **N9** — antiforgery / `Origin` validation for cookie flows. +29. Lows as hygiene pass. + +--- + +## Critical files to modify + +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` +- `Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs` +- `Idmt.Plugin/Configuration/IdmtOptions.cs` +- `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs` +- `Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs` +- `Idmt.Plugin/Models/IdmtUser.cs` (+ new `SysRole` column) +- `Idmt.Plugin/Features/Auth/Login.cs` +- `Idmt.Plugin/Features/Auth/Logout.cs` +- `Idmt.Plugin/Features/Auth/RefreshToken.cs` +- `Idmt.Plugin/Features/Auth/ResetPassword.cs` +- `Idmt.Plugin/Features/Auth/ConfirmEmail.cs` +- `Idmt.Plugin/Features/Auth/ForgotPassword.cs` +- `Idmt.Plugin/Features/Auth/DiscoverTenants.cs` +- `Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs` +- `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs` +- `Idmt.Plugin/Features/Manage/UpdateUser.cs` +- `Idmt.Plugin/Features/Manage/UnregisterUser.cs` +- `Idmt.Plugin/Features/Manage/GetUserInfo.cs` +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs` (rewritten) +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs` +- `Idmt.Plugin/Features/Admin/CreateTenant.cs` +- `Idmt.Plugin/Features/Admin/DeleteTenant.cs` +- `Idmt.Plugin/Features/Admin/GetAllTenants.cs` +- `Idmt.Plugin/Features/Admin/GetUserTenants.cs` +- `Idmt.Plugin/Services/TenantOperationService.cs` +- `Idmt.Plugin/Services/TokenRevocationService.cs` +- `Idmt.Plugin/Services/IdmtEmailSender.cs` +- `Idmt.Plugin/Services/IdmtLinkGenerator.cs` +- `Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs` +- `Idmt.Plugin/Persistence/IdmtDbContext.cs` +- `Idmt.Plugin/Features/AuthEndpoints.cs`, `ManageEndpoints.cs`, `AdminEndpoints.cs` +- `CLAUDE.md` (update data model description) +- New: EF migration + data migration script for shadow → canonical. + +--- + +## Verification plan + +Beyond happy-path assertions, critic surfaced bypass vectors not covered by simple endpoint tests. Add: + +1. **C1 access-token revocation** + - Mock time, issue bearer, logout, call protected endpoint → expect 401. + - Concurrent logout + protected-endpoint-call race: verify `DbUpdateException` branch at `TokenRevocationService.cs:43` handled without silent pass. + - Clock-skew test: revoke at T, token `IssuedUtc = T - 1ms`, verify revoked (exercises L5 `<` boundary). +2. **C2 SysAdmin enforcement** — SysSupport → `POST /admin/tenants`, `POST /admin/tenant-access/grant`, etc. → expect 403 each. +3. **C3 tenant-identifier replay** — seed canonical `alice@corp.com` with `TenantAccess` to A and B. Generate reset token in A, POST `/auth/reset-password` with tenant resolved from header = B. **Expect success** (same canonical user, one stamp). Then assert fix removes body-supplied `TenantIdentifier`. Cross-tenant reset now intended consequence of canonical model (one password) — document explicitly. +4. **C4 / N1 / N3** — `GrantTenantAccess` creates zero `IdmtUser` rows; only `TenantAccess` and optional `IdentityUserRole`. Assert atomic: kill outer DB between inner context execution and `SaveChanges` → no partial state. +5. **C5 / C6** — missing tenant header → 401, not 204/200. +6. **C7 email takeover** — attempt `ChangeEmailAsync`, assert new email staged but `Email` column unchanged; only confirmation link commits. `ForgotPassword` against staged (unconfirmed) email → reject. +7. **N2 context restore** — call `ExecuteInTenantScopeAsync` that throws, assert outer request's `IMultiTenantContextAccessor.MultiTenantContext` equals pre-call value. +8. **N5 refresh rotation** — present same refresh token twice → second call 401. +9. **H1 / H2 / N6 rate limiting** — 20 logins in 60s → 429; 3 forgot-password for same email in 5 min → 429. +10. **N4 response-length oracle** — `DiscoverTenants` for known and unknown email → identical Content-Length. +11. **H3 middleware ordering** — cross-tenant bearer token against tenant-B route → 401 before any authorization policy evaluates (hook observed via test logger). +12. **H4 `ClientUrl`** — startup with `ClientUrl=http://evil.com/foo` → options validation error. +13. **H5 transaction** — `UpdateUserInfo` with email change first + password change second, forced failure on password change → email already committed (document non-atomicity in test assertion). +14. **N7 audit coupling** — inject audit builder that throws for security-critical table write → business write rejected. +15. **N8 DP key ring** — startup without key ring in `Production` environment → error. +16. **Mixed auth with cross-tenant claim** — cookie for tenant A + request against tenant-B route → rejected (covers `ValidateBearerTokenTenantMiddleware` cookie path). +17. **Canonical migration sanity** — after migration, all duplicate `IdmtUser` rows gone; every `TenantAccess.UserId` resolves to extant canonical user; `SysRole` claim emitted correctly at login for sys users. +18. Run full suite: `dotnet test Idmt.slnx`, `dotnet format Idmt.slnx --verify-no-changes`, build with warnings-as-errors. + +Follow-up: dedicated `Idmt.SecurityTests` project exercising above as scenarios. \ No newline at end of file diff --git a/SECURITY_PHASE_0_FOUNDATION.md b/SECURITY_PHASE_0_FOUNDATION.md new file mode 100644 index 0000000..8029848 --- /dev/null +++ b/SECURITY_PHASE_0_FOUNDATION.md @@ -0,0 +1,159 @@ +# Phase 0 — Foundation + +Block every next phase. Ship first. + +--- + +## Project overview + +IDMT (Identity MultiTenant) Plugin — reusable NuGet lib for ASP.NET Core. Multi-tenant identity mgmt. Built on Finbuckle.MultiTenant + ASP.NET Core Identity. Per-tenant cookie isolation, hybrid cookie/bearer auth, vertical slice arch. ErrorOr for results, FluentValidation for requests. Target: net10.0. + +Key services + concepts: +- **Finbuckle.MultiTenant** resolve tenants via strategies (Header, Route, Claim, BasePath). +- `IdmtUser` extend `IdentityUser` as multi-tenant; `IdmtRole` per-tenant (docs sometimes wrong say global). +- `TenantAccess` map users to tenants with `IsActive` + optional `ExpiresAt`. +- Per-tenant cookie isolation: each tenant get separate auth cookie name. +- `ValidateBearerTokenTenantMiddleware` ensure bearer token tenant match request tenant. +- Two EF contexts: `IdmtDbContext` (multi-tenant app data) + `IdmtTenantStoreDbContext` (tenant metadata). +- `ITenantOperationService` run code in tenant-scoped DI scope. +- Pre-configured auth policies: `RequireSysAdmin`, `RequireSysUser`, `RequireTenantManager`, `CookieOnly`, `BearerOnly`. +- Token revoke via `ITokenRevocationService` + background cleanup (`TokenRevocationCleanupService`). +- Vertical slices under `Idmt.Plugin/Features/` grouped `Auth/`, `Manage/`, `Admin/`, `Health/`. + +Build/test: `dotnet build Idmt.slnx`, `dotnet test Idmt.slnx`, `dotnet format Idmt.slnx --verify-no-changes`. + +--- + +## Phase 0 scope + +Two items, both foundational: + +1. **N2 — `TenantOperationService` ambient-context restore** (Critical). Latent cross-tenant write-corruption bug; block Phase 1 canonical-migration work touching `ExecuteInTenantScopeAsync`. +2. **C2 — Admin policies need `RequireSysAdmin`, not `RequireSysUser`** (Critical). One-hour fix, block active privilege escalation. Ship now. + +Both model-agnostic — work under current shadow-row schema and under canonical-user schema in Phase 1. + +--- + +## Finding N2 (Critical) — `TenantOperationService` mutate ambient tenant context without restore + +### File +`Idmt.Plugin/Services/TenantOperationService.cs:33` + +### Detail +`ExecuteInTenantScopeAsync` resolve `IMultiTenantContextSetter` from child scope and write to it. But `IMultiTenantContextAccessor` in Finbuckle backed by `AsyncLocal` — writes via child-scope setter mutate ambient AsyncLocal for rest of async flow. No `try/finally` restore. + +Consequence: on return from delegate, outer-request `DbContext`, `UserManager`, `ICurrentUserService`, audit writer, any tenant-scoped service see tenant B (inner context) not outer request tenant. Any data written after delegate land under wrong tenant. + +`GrantTenantAccess.cs:181` already issue compensating re-entrant call — symptom of this confusion. + +### Attack / failure mode +Latent. No current handler write data *after* `ExecuteInTenantScopeAsync` return, so no exploit today. But one-commit-away cross-tenant write-corruption bug: soon as future handler run inner-tenant op then outer-tenant write (audit row, telemetry, follow-up `SaveChanges`), writes land wrong tenant. + +Also, if delegate throw, AsyncLocal left mutated for rest of HTTP request. + +### Fix +```csharp +public async Task ExecuteInTenantScopeAsync(IdmtTenantInfo target, Func operation) +{ + using var scope = serviceScopeFactory.CreateScope(); + var setter = scope.ServiceProvider.GetRequiredService(); + var accessor = scope.ServiceProvider.GetRequiredService(); + var previous = accessor.MultiTenantContext; + try + { + setter.MultiTenantContext = new MultiTenantContext { TenantInfo = target }; + await operation(scope.ServiceProvider); + } + finally + { + setter.MultiTenantContext = previous; + } +} +``` + +Document invariant: outer request tenant context transient unstable during delegate, but restored before delegate task complete. + +### Files to modify +- `Idmt.Plugin/Services/TenantOperationService.cs` + +### Verification +- Unit test: call `ExecuteInTenantScopeAsync` where delegate throw; assert outer-scope `accessor.MultiTenantContext` equal pre-call value. +- Unit test: same, delegate return normal; assert restoration. +- Unit test: delegate mutate tenant B, write entity, then outer scope write another entity — assert each entity persist under intended tenant filter. + +### Why this must be Phase 0 +Phase 1 rewrite `GrantTenantAccess` and `ConfirmEmail` / `ResetPassword`, all route through `ExecuteInTenantScopeAsync`. Ship Phase 1 on broken service cement compensating-action pattern into new code paths. Fix service first. + +--- + +## Finding C2 (Critical) — Admin endpoints guard by `RequireSysUser` not `RequireSysAdmin` + +### Files +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:426-432` +- `Idmt.Plugin/Features/AdminEndpoints.cs:14` +- `Idmt.Plugin/Features/Admin/DeleteTenant.cs:74` +- `Idmt.Plugin/Features/Admin/CreateTenant.cs` +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs:239` +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs:116` +- `Idmt.Plugin/Features/Admin/GetAllTenants.cs:91` +- `Idmt.Plugin/Features/Admin/GetUserTenants.cs:102` + +### Detail +`RequireSysAdminPolicy` defined in `IdmtAuthOptions` but **never referenced**. `RequireSysUserPolicy = SysAdmin OR SysSupport`. Admin endpoints group in `AdminEndpoints.cs:14` use `RequireSysUser`, so SysSupport accounts can create/delete tenants and grant self tenant access. + +### Attack +SysSupport user: +1. `POST /admin/tenants` → create new tenant. +2. `POST /admin/tenant-access/grant` with `{ userId: self, tenantIdentifier: "target-tenant" }` → grant self access to any existing tenant. +3. Log into that tenant as role assigned during grant (often TenantAdmin). + +Full privilege escalation from Support tier to arbitrary tenant admin. + +### Fix +1. **Tenant lifecycle** (`CreateTenant`, `DeleteTenant`) and **tenant-access mutations** (`GrantTenantAccess`, `RevokeTenantAccess`) must require `RequireSysAdminPolicy`. +2. **Listing endpoints** (`GetAllTenants`, `GetUserTenants`) may stay on `RequireSysUserPolicy` (SysSupport has legit read access). +3. Add self-grant guard in `GrantTenantAccess`: reject when `request.UserId == currentUserService.UserId`. +4. Apply policy **both** at group level (`AdminEndpoints.cs`) **and** endpoint level (`.RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy)` on each `Map*Endpoint`) for defense-in-depth. Close M6 gap (endpoints like `CreateTenant.cs:132-159` rely on group-level auth only today). + +### Files to modify +- `Idmt.Plugin/Features/AdminEndpoints.cs` — split group or add sub-group for SysAdmin-only endpoints. +- `Idmt.Plugin/Features/Admin/CreateTenant.cs` +- `Idmt.Plugin/Features/Admin/DeleteTenant.cs` +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs` +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs` +- `Idmt.Plugin/Features/Admin/GetAllTenants.cs` +- `Idmt.Plugin/Features/Admin/GetUserTenants.cs` + +### Verification +- Integration test: seed SysSupport user; try `POST /admin/tenants` → expect 403. +- Integration test: SysSupport → `POST /admin/tenant-access/grant` → 403. +- Integration test: SysSupport → `GET /admin/tenants` → 200. +- Integration test: SysAdmin grant-access where `userId == caller.userId` → 403 with `IdmtErrors.General.SelfTarget`. +- Integration test: SysAdmin → `POST /admin/tenants` → 201. + +### Why Phase 0 +Active privilege-escalation path. Ship within hour. No arch dependency; work under current shadow-row model and Phase 1 canonical model. + +--- + +## Phase 0 implementation order + +1. **N2 first.** Wrap delegate in `try/finally`. Ship + test. Nothing else depend on phase 0 state except Phase 1. +2. **C2 second.** Mechanical policy rename + endpoint-level redundancy + self-grant guard. Ship independent. + +Both PR parallel if work split across contributors. + +--- + +## Phase 0 done-criteria + +- `TenantOperationService.ExecuteInTenantScopeAsync` restore ambient tenant context on normal return and on throw. +- All SysAdmin-only admin endpoints reject SysSupport callers with 403. +- `GrantTenantAccess` reject self-target with domain error. +- All admin endpoints have endpoint-level `.RequireAuthorization` plus group-level. +- `dotnet test Idmt.slnx` pass. +- `dotnet format Idmt.slnx --verify-no-changes` pass. +- `dotnet build Idmt.slnx` with warnings-as-errors pass. + +Phase 1 begin when all above satisfied. \ No newline at end of file diff --git a/SECURITY_PHASE_0_IMPLEMENTATION.md b/SECURITY_PHASE_0_IMPLEMENTATION.md new file mode 100644 index 0000000..9ff1dc8 --- /dev/null +++ b/SECURITY_PHASE_0_IMPLEMENTATION.md @@ -0,0 +1,340 @@ +# Phase 0 — Implementation Plan + +Derived from `SECURITY_PHASE_0_FOUNDATION.md` with amendments from the architect-critic pass. Safe to execute. No Phase 1 / canonical-migration work here. + +--- + +## Context + +IDMT plugin at `/home/iuri/code/idmt-plugin`. Two Critical findings ship in Phase 0: +- **N2** — `TenantOperationService.ExecuteInTenantScopeAsync` mutates AsyncLocal-backed ambient tenant context without restoring on exit. Latent cross-tenant write corruption. Blocks Phase 1 rewrite of `GrantTenantAccess`, `ConfirmEmail`, `ResetPassword` (all route through this service). +- **C2** — Admin endpoints guarded by `RequireSysUserPolicy` (SysAdmin OR SysSupport). SysSupport can create/delete tenants and grant self tenant access → active privilege escalation today. + +Critic amendments applied below: +1. Fix snippet in Phase 0 doc uses wrong signature — actual `TenantOperationService` returns `ErrorOr`, takes `string tenantIdentifier`, has `requireActive` param, constructs `MultiTenantContext` positionally. +2. Endpoint-level auth survey: only `CreateTenant` lacks `.RequireAuthorization` at endpoint level; others already have `RequireSysUserPolicy` endpoint-level. +3. AND-semantics trap: minimal-API `MapGroup(...).RequireAuthorization(A)` plus per-endpoint `.RequireAuthorization(B)` accumulates — caller must satisfy both. Strategy: keep group on `RequireSysUser`, override mutation endpoints to `RequireSysAdmin`, keep listing endpoints on `RequireSysUser`. +4. Self-grant guard must run before any DB lookup to avoid timing oracle. +5. Verification must cover nested delegates, concurrent delegates, both auth schemes (cookie + bearer). + +Finbuckle premise confirmed: `IMultiTenantContextAccessor` is Singleton backed by `AsyncLocalMultiTenantContextAccessor` (AsyncLocal). `IMultiTenantContextSetter` resolves to the same instance. Mutation from any scope mutates ambient flow. + +--- + +## Prerequisites + +- Branch: `v1-improvements` (current). +- No migrations or DB changes in Phase 0. +- Consumers unaffected by Phase 0 API changes (signature of `TenantOperationService` unchanged; policy/error additions are backward-compatible for non-SysSupport callers). + +--- + +## Tasks + +### Task 1 — N2: `TenantOperationService` try/finally context restore + +**File**: `Idmt.Plugin/Services/TenantOperationService.cs` + +**Change** (only the generic overload — the non-generic overload at line 39-45 delegates to the generic, so one edit covers both): + +Replace the body of `ExecuteInTenantScopeAsync` (lines 11-37) with: + +```csharp +public async Task> ExecuteInTenantScopeAsync( + string tenantIdentifier, + Func>> operation, + bool requireActive = true) +{ + using var scope = serviceProvider.CreateScope(); + var provider = scope.ServiceProvider; + + var tenantStore = provider.GetRequiredService>(); + var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier); + + if (tenantInfo is null) + { + return IdmtErrors.Tenant.NotFound; + } + + if (requireActive && !tenantInfo.IsActive) + { + return IdmtErrors.Tenant.Inactive; + } + + var setter = provider.GetRequiredService(); + var accessor = provider.GetRequiredService(); + var previous = accessor.MultiTenantContext; + try + { + setter.MultiTenantContext = new MultiTenantContext(tenantInfo); + return await operation(provider); + } + finally + { + setter.MultiTenantContext = previous; + } +} +``` + +Key points: +- `using` statement on scope retained (unchanged). +- `IMultiTenantContextAccessor` resolved from the same child scope as the setter — both are Singletons, so they point to the same AsyncLocal slot. +- `previous` captured *after* successful tenant resolution (if the tenant doesn't exist or is inactive, no mutation happens, no restore needed). +- Positional ctor `new MultiTenantContext(tenantInfo)` matches existing code (line 34 pre-fix). +- `finally` runs on both normal return and throw; restores ambient state. +- Non-generic overload at lines 39-45 untouched — it delegates to this one. + +**Add XML doc on the method** documenting invariants: +- The ambient `IMultiTenantContextAccessor.MultiTenantContext` is transiently set to `tenantIdentifier`'s context during the delegate; it is restored to its pre-call value before this task completes. +- AsyncLocal is flow-scoped, not per-task. Do **not** invoke concurrent `ExecuteInTenantScopeAsync` calls on the same async flow (e.g., `Task.WhenAll(ExecuteInTenantScopeAsync(a, ...), ExecuteInTenantScopeAsync(b, ...))`) — the two calls race on the AsyncLocal slot and the "previous" capture becomes undefined. Only nested (sequential) calls are safe. + +### Task 2 — N2 unit tests + +**New file**: `tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs` (or extend if exists). + +Tests to add: +1. **Sequential restore on normal return**: pre-populate accessor with tenant X context; call `ExecuteInTenantScopeAsync("tenant-y", _ => Ok)`; assert post-call accessor is tenant X. +2. **Sequential restore on throw**: same setup; delegate throws; outer catch; assert post-call accessor is tenant X. +3. **Restore from null pre-context**: accessor starts null/empty; call the method; delegate observes tenant Y; assert post-call accessor is null/empty. +4. **Nested calls unwind correctly**: outer tenant X → call method with tenant Y → delegate calls method again with tenant Z → assert delegate-inside-delegate observes Z, return to outer-delegate observes Y, return to test observes X. +5. **Delegate observes inner tenant**: delegate reads `IMultiTenantContextAccessor.MultiTenantContext.TenantInfo?.Identifier` and asserts it equals `"tenant-y"`. +6. **Tenant-not-found short-circuit doesn't mutate context**: pre-context X; call with nonexistent tenant; delegate never runs; post-context still X. +7. **Tenant-inactive + requireActive=true short-circuit doesn't mutate context**: similar. +8. **Concurrent-delegate limitation is documented, not tested**: deliberately do NOT add a concurrent-`Task.WhenAll` test — AsyncLocal race behavior under `WhenAll` is implementation-defined and flaky in CI. The XML-doc invariant (Task 1) forbids this usage; a test that locks in racy behavior would create false confidence. Instead, add a short unit test asserting that the XML doc exists (via reflection on the method's `[EditorBrowsable]` or a string grep in a build-time test) — optional, low value. + +Use `Finbuckle.MultiTenant.InMemoryStore` or a minimal fake `IMultiTenantStore`. Resolve `IMultiTenantContextAccessor`, `IMultiTenantContextSetter` via a `ServiceCollection` with `services.AddMultiTenant()` to ensure Singleton/AsyncLocal behavior is realistic. + +### Task 3 — C2 policy matrix comment + +**File**: `Idmt.Plugin/Features/AdminEndpoints.cs` + +**Change**: leave the group `.RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)` as-is. Add a comment block above it documenting the AND-semantics policy design: + +```csharp +// Group policy: RequireSysUser (SysAdmin OR SysSupport) — minimum bar for /admin/*. +// Per-endpoint policies further restrict mutations to SysAdmin only. +// Due to ASP.NET Core minimal-API AND-semantics on .RequireAuthorization, +// an endpoint with both group=SysUser + endpoint=SysAdmin requires SysAdmin (stricter wins). +``` + +### Task 4 — C2: `CreateTenant` add endpoint-level SysAdmin auth + +**File**: `Idmt.Plugin/Features/Admin/CreateTenant.cs` + +**Change** at the `MapCreateTenantEndpoint` method (~line 132-159): + +Add (position at the end of the endpoint builder chain, matching the pattern in `DeleteTenant.cs:74`): + +```csharp +.RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy) +``` + +### Task 5 — C2: promote mutation endpoints from SysUser → SysAdmin + +Three endpoints currently have endpoint-level `.RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)`; change each to `RequireSysAdminPolicy`: + +- `Idmt.Plugin/Features/Admin/DeleteTenant.cs:74` +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs:239` +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs:116` + +Leave these two on `RequireSysUserPolicy` (listing, SysSupport has legitimate read access): + +- `Idmt.Plugin/Features/Admin/GetAllTenants.cs:91` +- `Idmt.Plugin/Features/Admin/GetUserTenants.cs:102` + +### Task 6 — C2 self-grant guard + +**File**: `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs` + +**Actual handler signature** (verified): +```csharp +public async Task> HandleAsync(Guid userId, string tenantIdentifier, DateTimeOffset? expiresAt = null, CancellationToken cancellationToken = default) +``` +Ctor at line 33-40 does **not** inject `ICurrentUserService`. Must add it. + +**Ctor change** — add `ICurrentUserService currentUserService` parameter: +```csharp +internal sealed class GrantTenantAccessHandler( + IdmtDbContext dbContext, + UserManager userManager, + IMultiTenantStore tenantStore, + ITenantOperationService tenantOps, + TimeProvider timeProvider, + ICurrentUserService currentUserService, // <-- ADD + ILogger logger +) : IGrantTenantAccessHandler +``` + +**Guard placement** — top of `HandleAsync`, before any DB/tenant-store lookup (current body starts at line 44 with `expiresAt` validation; place guard before that): + +```csharp +public async Task> HandleAsync(Guid userId, string tenantIdentifier, DateTimeOffset? expiresAt = null, CancellationToken cancellationToken = default) +{ + // Fail closed: unauthenticated context must not reach this handler. If it does, refuse. + if (currentUserService.UserId is not Guid callerId) + { + return IdmtErrors.Auth.Unauthorized; + } + if (callerId == userId) + { + return IdmtErrors.General.SelfTarget; + } + + if (expiresAt.HasValue && expiresAt.Value <= timeProvider.GetUtcNow()) + { + return Error.Validation("ExpiresAt", "Expiration date must be in the future"); + } + + // ... existing DB / tenant-scope execution +} +``` + +Null-branch fails closed (401) — don't silently fall through. Self-match returns 403 `General.SelfTarget`. Guard executes before any DB or tenant lookup → no timing oracle. + +### Task 7 — C2 error code + endpoint-delegate mapping + +**File**: `Idmt.Plugin/Errors/IdmtErrors.cs` + +`General` nested class already exists (holds `Unexpected`). Add alongside: + +```csharp +public static Error SelfTarget => Error.Forbidden( + code: "General.SelfTarget", + description: "This operation cannot target the caller."); +``` + +**File**: `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs:218-240` + +Current delegate signature: `Task>`. Switch at line 230 maps only `Validation` and `NotFound`. `Forbidden` falls through to `InternalServerError`. Must update: + +```csharp +public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRouteBuilder endpoints) +{ + return endpoints.MapPost("/users/{userId:guid}/tenants/{tenantIdentifier}", + async Task> ( + Guid userId, + string tenantIdentifier, + [FromBody] GrantAccessRequest request, + IGrantTenantAccessHandler handler, + CancellationToken cancellationToken) => + { + var result = await handler.HandleAsync(userId, tenantIdentifier, request.ExpiresAt, cancellationToken); + if (result.IsError) + { + return result.FirstError.Type switch + { + ErrorType.Validation => TypedResults.BadRequest(), + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Unauthorized => TypedResults.Unauthorized(), + _ => TypedResults.InternalServerError(), + }; + } + return TypedResults.Ok(); + }) + .RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy) // promoted per Task 5 + .WithSummary("Grant user access to a tenant"); +} +``` + +`Forbidden` → 403 and `Unauthorized` → 401 (covers the null-`UserId` fail-closed branch in Task 6). + +**Bonus — while in the file at `RevokeTenantAccess.cs:107-112`**: the delegate there also lacks a `Forbidden` branch. Add the same `Forbidden`/`Unauthorized` mappings + union entries for consistency. Not strictly required for Phase 0 exploit closure, but avoids a known-stale mapping for future handlers. + +`Forbidden` is correct for self-target (authenticated, syntactically valid, policy-denied); `Unauthorized` is correct when the caller's `UserId` cannot be resolved. + +### Task 8 — C2 integration tests + +**New or existing**: `tests/Idmt.BasicSample.Tests/AdminEndpointsTests.cs` (or split per endpoint). + +Seed: test tenant store with two tenants (`tenant-a`, `tenant-b`); users: one SysAdmin, one SysSupport, one regular user. + +Cases: +1. **SysSupport blocked from mutations (cookie auth)**: + - `POST /admin/tenants` (create) → 403. + - `DELETE /admin/tenants/{id}` → 403. + - `POST /admin/tenant-access/grant` → 403. + - `POST /admin/tenant-access/revoke` → 403. +2. **SysSupport allowed on listings (cookie auth)**: + - `GET /admin/tenants` → 200. + - `GET /admin/tenants/{tenantId}/users` (or equivalent `GetUserTenants` path) → 200. +3. **SysAdmin allowed on everything (cookie auth)**: + - All six endpoints → 2xx with valid payload. +4. **Bearer path parity** — re-run 1-3 using bearer tokens to verify `CookieOrBearerScheme` selector doesn't bypass the policy. +5. **Self-grant** — SysAdmin calls `POST /admin/tenant-access/grant` with `userId == caller.userId` → 403 with body error code `General.SelfTarget`. Assert via mocked `ITenantOperationService` that `ExecuteInTenantScopeAsync` was **never called** — proves the guard fires before any tenant-scope entry (no timing oracle via DB latency). +5a. **Unauthenticated handler call** (unit test, not integration) — mock `ICurrentUserService.UserId` returning `null`; call `HandleAsync` directly → returns `IdmtErrors.Auth.Unauthorized`. Asserts fail-closed on null caller. +6. **Cross-grant still works** — SysAdmin grants a different user → 200. +7. **Listing regression** — SysAdmin gets `GetAllTenants` → 200 (confirms the stricter mutation policy didn't accidentally demote the listing). + +--- + +## Implementation order + +1. **Task 1** (N2 code). +2. **Task 2** (N2 tests). +3. **Task 7** (error code — prerequisite for Tasks 5/6/8). +4. **Task 6** (self-grant guard) + **Task 4** (CreateTenant endpoint-level auth) + **Task 5** (policy promotion). These three can land together. +5. **Task 3** (comment on AdminEndpoints). +6. **Task 8** (integration tests). + +One PR per cluster: PR1 = Tasks 1+2 (N2). PR2 = Tasks 3+4+5+6+7+8 (C2 with tests). + +--- + +## Files to modify + +- `Idmt.Plugin/Services/TenantOperationService.cs` — try/finally wrapper (Task 1). +- `Idmt.Plugin/Features/AdminEndpoints.cs` — doc comment (Task 3). +- `Idmt.Plugin/Features/Admin/CreateTenant.cs` — add endpoint-level SysAdmin auth (Task 4). +- `Idmt.Plugin/Features/Admin/DeleteTenant.cs` — promote to SysAdmin (Task 5). +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs` — promote to SysAdmin + self-grant guard (Tasks 5, 6). +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs` — promote to SysAdmin (Task 5). +- `Idmt.Plugin/Errors/IdmtErrors.cs` — `General.SelfTarget` error (Task 7). +- `tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs` — N2 tests (Task 2). +- `tests/Idmt.BasicSample.Tests/AdminEndpointsTests.cs` — C2 integration tests (Task 8). + +--- + +## Verification + +### Commands + +```bash +dotnet build Idmt.slnx +dotnet test Idmt.slnx +dotnet format Idmt.slnx --verify-no-changes +``` + +All must pass with warnings-as-errors. + +### Acceptance criteria + +- `TenantOperationService.ExecuteInTenantScopeAsync` restores ambient `IMultiTenantContextAccessor.MultiTenantContext` on normal return and on throw (sequential + nested). +- `CreateTenant`, `DeleteTenant`, `GrantTenantAccess`, `RevokeTenantAccess` reject SysSupport callers with 403 via both cookie and bearer auth. +- `GetAllTenants`, `GetUserTenants` continue to accept SysSupport callers. +- `GrantTenantAccess` with `request.UserId == caller.UserId` returns 403 `General.SelfTarget` before any DB lookup (assert via mocked `ITenantOperationService` — ensure it was never called). +- Full suite green: `dotnet test` + format + warnings-as-errors build. + +--- + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| `IMultiTenantContextSetter` / `IMultiTenantContextAccessor` behaviour changes in a future Finbuckle major | Tests cover concrete behaviour (Task 2 #4, #5) — would detect regression. | +| `currentUserService.UserId` is `Guid?` vs `Guid`; null-coalescing trap | Use `is Guid callerId &&` pattern (see Task 6 snippet) — implicitly guards null. | +| Consumers depend on `RequireSysUserPolicy` at mutation endpoints (breaking change) | Branch `v1-improvements` signals v1 cut. Tag this as a breaking change in CHANGELOG / release notes. Consumers who legitimately granted SysSupport mutation rights must re-provision those accounts as SysAdmin. If an opt-out escape hatch is required for gradual rollout, expose an `IdmtOptions.Admin.AllowSysSupportMutations = false` flag (default false, opt-in to legacy behavior, deprecated). Not implementing the flag in Phase 0 unless the product owner requests it. | +| Self-grant guard's error type mismatch (Forbidden vs Validation) | Explicit choice of `Error.Forbidden` in Task 7; document the rationale in the error XML doc. | +| Phase 0 ships before N3 / partial-failure window is fixed | Intentional — N3 is resolved in Phase 1 (rewrite of `GrantTenantAccess`). Phase 0 does not regress N3; the compensating call at `GrantTenantAccess.cs:181` continues to work, and under N2's fix its context is correctly scoped. | + +--- + +## Out of scope for Phase 0 + +Do **not** include in this implementation: +- Canonical-user migration (Phase 1). +- `GrantTenantAccess` handler rewrite beyond the self-grant guard (Phase 1). +- Any token / revocation work (Phase 2). +- Middleware reordering (Phase 3). +- Rate limiting or default changes (Phase 3). +- Any additional Lows / Mediums that don't appear above (Phase 4). diff --git a/SECURITY_PHASE_1_CANONICAL_IDENTITY.md b/SECURITY_PHASE_1_CANONICAL_IDENTITY.md new file mode 100644 index 0000000..da5a3ee --- /dev/null +++ b/SECURITY_PHASE_1_CANONICAL_IDENTITY.md @@ -0,0 +1,236 @@ +# Phase 1 — Canonical Identity Migration + +Foundational data-model change. Fix several Critical findings at root, not patch symptoms. Depend on Phase 0 (N2 context-restore). + +--- + +## Project overview + +IDMT (Identity MultiTenant) Plugin — reusable NuGet library for ASP.NET Core, multi-tenant identity management. Built on Finbuckle.MultiTenant + ASP.NET Core Identity, per-tenant cookie isolation, hybrid cookie/bearer auth, vertical slice architecture. Use ErrorOr for results, FluentValidation for requests. Target: net10.0. + +Key services + concepts: +- **Finbuckle.MultiTenant** resolve tenants via strategies (Header, Route, Claim, BasePath). +- Today `IdmtUser` extend `IdentityUser` as multi-tenant; `IdmtRole` per-tenant. This Phase make `IdmtUser` global. +- `TenantAccess` map users to tenants with `IsActive` + optional `ExpiresAt`. +- Per-tenant cookie isolation: each tenant get separate auth cookie name. +- `ValidateBearerTokenTenantMiddleware` ensure bearer token tenant match request tenant. +- Two EF contexts: `IdmtDbContext` (multi-tenant app data) + `IdmtTenantStoreDbContext` (tenant metadata). +- `ITenantOperationService` run code in tenant-scoped DI scope (fixed Phase 0). +- Pre-configured auth policies: `RequireSysAdmin`, `RequireSysUser`, `RequireTenantManager`, `CookieOnly`, `BearerOnly`. +- Token revocation via `ITokenRevocationService` + background cleanup (`TokenRevocationCleanupService`). + +Build/test: `dotnet build Idmt.slnx`, `dotnet test Idmt.slnx`, `dotnet format Idmt.slnx --verify-no-changes`. + +--- + +## Architectural decision + +**Canonical `IdmtUser` + `TenantAccess` + global `SysRole` column.** + +### Rationale + +Current code store one `IdmtUser` row per tenant. `GrantTenantAccess.cs:117-133` create shadow row in target tenant, copy `PasswordHash` + `LockoutEnd`, generate fresh `Id` + `SecurityStamp`. Model make coherent identity ops impossible across tenants: +- Password rotation update only current-tenant row. +- `UpdateSecurityStampAsync` affect only current-tenant row. +- `TokenRevocationService.RevokeUserTokensAsync(userId, tenantId)` key on *row-specific* `userId`; shadow row in another tenant have different `userId`, so revocations no cross tenants. +- Lockout state no propagate. +- Email-change state drift. + +Primary use case for `GrantTenantAccess` per product intent: SysUsers hop into any tenant. Secondary: normal user with multi-tenant membership. Canonical model serve both without shadow rows. + +### Target schema + +``` +IdmtUser (global — drop IsMultiTenant) + Id, Email, NormalizedEmail, PasswordHash, SecurityStamp, + LockoutEnd, EmailConfirmed, IsActive, ... + SysRole : SysRoleKind // non-null enum: None | SysAdmin | SysSupport + // default = None (stored as int) + +IdmtRole (per-tenant, IsMultiTenant — unchanged) + Id, Name, TenantId + Populated with TenantAdmin, TenantUser, or consumer-defined roles. + Drop pre-seeded SysAdmin/SysSupport rows — they move to IdmtUser.SysRole. + +IdentityUserRole (per-tenant, IsMultiTenant — unchanged) + UserId -> IdmtUser.Id, RoleId -> IdmtRole.Id, TenantId (Finbuckle shadow) + +TenantAccess (per-tenant — unchanged) + UserId, TenantId, IsActive, ExpiresAt +``` + +### Flow impact + +| Flow | Before | After | +|---|---|---| +| SysUser into tenant B | `GrantTenantAccess` clone user, copy hash, compensation window | Set `IdmtUser.SysRole = SysAdmin`. No clone, no `TenantAccess` row required. Work in every tenant immediately. | +| Normal user granted role in tenant B | `TenantAccess` + `IdentityUserRole` per tenant | Unchanged. | +| Sys + tenant role combo | Clone + role assign in shadow | `SysRole` set + per-tenant `IdentityUserRole` row. Factory emit both claims. | +| Password rotation | Only update tenant-A hash | One hash, coherent. | +| Security-stamp invalidation | Per-tenant only | One stamp, coherent. | +| Token revocation by `(userId, tenantId)` | Wrong `userId` for shadow rows | One canonical `userId`; per-tenant revoke still valid. | +| Email change | Per-tenant | One place. Document as intentional. | + +### Claim assembly change (`IdmtUserClaimsPrincipalFactory.cs:26`) + +```csharp +var roles = await userManager.GetRolesAsync(user); // per-tenant (Finbuckle-filtered) — unchanged +foreach (var role in roles) + identity.AddClaim(new Claim(ClaimTypes.Role, role)); +if (user.SysRole != SysRoleKind.None) + identity.AddClaim(new Claim(ClaimTypes.Role, user.SysRole.ToString())); +``` + +### Finbuckle integration + +`IdmtUser` entity drop `.IsMultiTenant()` in `IdmtDbContext.cs:99-102`. Two options: +- **(a)** Keep in `IdmtDbContext` with no tenant filter on `IdmtUser` only. Less invasive — one `modelBuilder.Entity()` adjustment — and keep Identity's UserStore resolver pointed at single context. +- **(b)** Move to `IdmtTenantStoreDbContext` (global store). Larger refactor; cleaner conceptually. + +Recommend **(a)** this phase. + +`UserManager.FindByEmailAsync` / `FindByIdAsync` resolve globally. `GetRolesAsync` still filter per-tenant via `IdentityUserRole` multi-tenancy. No change to Identity APIs. + +### Blast-radius note + +Canonical model mean compromise of user's hash grant access to every tenant they in. Current shadow model *appear* to bound this but share hash via `GrantTenantAccess.cs:117-133`, so canonical strictly better: rotation + stamp invalidation now work. Document explicit in CLAUDE.md + release notes. + +### Migration for existing deployments + +Offline script (document as breaking change; bump major version): +1. Group existing `IdmtUser` rows by `NormalizedEmail`. Pick canonical `Id` (oldest row). +2. Rewrite `TenantAccess.UserId`, `IdentityUserRole.UserId`, `RevokedToken.UserId`, audit rows to canonical id. +3. Merge `SysRole` from any tenant row where user was `SysAdmin`/`SysSupport` → set on canonical row. All other rows default `SysRoleKind.None`. +4. Drop duplicate `IdmtUser` rows. +5. Force password reset for all migrated users — hashes may have diverged across shadows. + +Provide idempotent SQL/EF script + rollback plan + deployment runbook. + +--- + +## Phase 1 findings + +### 1. Schema + code migration + +Implementation order this phase: +1. Add `SysRoleKind` enum + `SysRole` column to `IdmtUser`. +2. Remove `IsMultiTenant()` from `IdmtUser` entity in `IdmtDbContext`. +3. Adjust claim factory to emit `SysRole` claim. +4. EF migration: column add + (new deployments) seed-role cleanup. +5. Data migration script for existing deployments (see above). +6. Update CLAUDE.md: `IdmtUser` global, `IdmtRole` per-tenant, `SysRole` global. + +### 2. Rewrite `GrantTenantAccess` (subsumes C4, N1, N3) + +Originally three findings: +- **C4**: `GrantTenantAccess.cs:117-133` copy `PasswordHash`, `LockoutEnd` verbatim into shadow row. `CreateAsync(user)` (no password arg) persist hash directly. Hash-copy = root cause of stamp/hash drift. +- **N1**: Revocation keying incoherent across tenants (shadow rows have different `userId`). `RevokeTenantAccess.cs:67-80` revoke by caller's `userId`, then flip `IsActive` on *different* row. Admin "revoke" in tenant A leave tenant-B bearer session alive 60 min. +- **N3**: Partial-failure window at `GrantTenantAccess.cs:106-214`. Tenant-B user committed inside `ExecuteInTenantScopeAsync` (~line 152) *before* outer `dbContext.SaveChangesAsync` for `TenantAccess` (line 171). If outer save fails or request cancelled, tenant-B user exists without `TenantAccess` row. Compensation (line 181+) best-effort; `LogCritical` fire if compensation throws. + +Under canonical model all three collapse: +- No `IdmtUser` creation — canonical user already exist. +- Handler only write: + - `TenantAccess(UserId=canonical, TenantId=target, IsActive=true, ExpiresAt=...)` row. + - Optional `IdentityUserRole(UserId=canonical, RoleId, TenantId=target)` row if role requested. +- Single `SaveChangesAsync`, single transaction. No compensation logic. +- SysUsers (anyone with `SysRole != None`) reach any tenant *without* `TenantAccess` row — grant only required for normal-user cross-tenant access. + +Also fix **H7**: current code at `GrantTenantAccess.cs:113,187` and `RevokeTenantAccess.cs:80` use `.FirstOrDefaultAsync(u => u.Email == x && u.UserName == x)` — case-sensitive, vulnerable to null-username collision. Use `FindByEmailAsync` + assert identity via `Id` equality. + +### 3. C7 — email-change + reset-password account takeover chain + +**Files**: `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs:86-104`, `Idmt.Plugin/Features/Auth/ResetPassword.cs:52-56`. + +**Detail**: `UpdateUserInfo` generate own change-email token inline + call `ChangeEmailAsync` immediately — no out-of-band confirmation of new address. `ResetPassword` silently set `user.EmailConfirmed = true` after successful reset. + +**Attack**: attacker with temp session → `PUT /manage/info` with `NewEmail` = attacker's address → `Email` column rebound, `EmailConfirmed` = false → attacker call `ForgotPassword` on new email (they control) → reset password → `EmailConfirmed` silently flip to `true`. Account now fully bound to attacker, no victim-side confirmation ever sent. + +**Fix**: +1. `UpdateUserInfo` stage new email in pending column (e.g., `IdmtUser.PendingEmail`) without touching `Email`. Send confirmation link to *new* address. Only upon click link + submit valid token does `Email` update + `EmailConfirmed` = true. +2. `ResetPassword` stop setting `EmailConfirmed = true` as side effect. Password reset prove mailbox possession at *current* `Email`, not new one. + +**Implementation**: +- Add `PendingEmail` (nullable string) + `PendingEmailTokenHash` (nullable string) to `IdmtUser`. Or reuse Identity's built-in change-email token mechanism properly. +- New endpoint `POST /auth/confirm-email-change` validate token + commit `Email` swap. +- `UpdateUserInfo` return 202 (accepted, pending confirmation) when email change requested; other fields update immediately. +- `ResetPassword`: remove `EmailConfirmed = true` line. + +### 4. C3 (demoted) — body-supplied `TenantIdentifier` + +**Files**: `Idmt.Plugin/Features/Auth/ConfirmEmail.cs:21,32-57`, `Idmt.Plugin/Features/Auth/ResetPassword.cs:21,32-66`. + +**Detail**: Both handlers accept `TenantIdentifier` in request body + pass to `ITenantOperationService.ExecuteInTenantScopeAsync`. Decouple token handling from request's tenant strategy. + +Original claim (reset-token replay across tenants) not exploitable because shadow rows had independent `Id` + `SecurityStamp`. Under canonical model, same canonical user has one stamp + one password, so reset global anyway (intentional). Body-supplied `TenantIdentifier` = hygiene gap: create regression trap if anyone ever reinstate copied `Id`/`SecurityStamp`. + +**Fix**: remove `TenantIdentifier` from `ConfirmEmailRequest` + `ResetPasswordRequest`. Resolve tenant from request context (header/claim/route) like every other handler. Reject if unresolvable. + +### 5. Update CLAUDE.md (subsumes L10) + +- `IdmtUser` global (not per-tenant). +- `IdmtRole` remain per-tenant (correct existing doc claim of "global"). +- `SysRole` column on `IdmtUser` global — store `None | SysAdmin | SysSupport`. +- `TenantAccess` control cross-tenant access for non-sys users; sys users no need `TenantAccess` rows. +- Password + security-stamp now single-source; rotations propagate everywhere automatic. + +--- + +## Dependencies + +- **Phase 0 must complete.** N2 fix required before rewriting `GrantTenantAccess`, `ConfirmEmail`, `ResetPassword`, all use `ExecuteInTenantScopeAsync`. Without N2, outer-request context corrupted after delegate returns. +- Canonical migration = breaking change at DB layer — require coordinated deployment with consumer apps. + +--- + +## Files to modify + +- `Idmt.Plugin/Models/IdmtUser.cs` — add `SysRole` property (`SysRoleKind` enum, default `None`). +- `Idmt.Plugin/Models/SysRoleKind.cs` — new enum file (`None = 0, SysAdmin = 1, SysSupport = 2`). +- `Idmt.Plugin/Persistence/IdmtDbContext.cs` — drop `IsMultiTenant()` on `IdmtUser` entity; update model config. +- `Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs` — emit `SysRole` as role claim when `!= None`. +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs` — full rewrite (delete shadow-user creation; single-transaction `TenantAccess` + optional `IdentityUserRole`). +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs` — remove cross-row lookup; revoke by canonical `userId`. +- `Idmt.Plugin/Features/Auth/ConfirmEmail.cs` — remove `TenantIdentifier` from request record; resolve from context. +- `Idmt.Plugin/Features/Auth/ResetPassword.cs` — remove `TenantIdentifier`; remove `EmailConfirmed = true`. +- `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs` — stage new email instead of commit; issue OOB confirmation link. +- `Idmt.Plugin/Features/AuthEndpoints.cs` — add `POST /auth/confirm-email-change` endpoint. +- `Idmt.Plugin/Validation/*` — update validators for new/removed fields. +- `Idmt.Plugin/Services/IdmtLinkGenerator.cs` — add email-change confirmation link generator method. +- EF migration: new column + index, drop SysAdmin/SysSupport pre-seeded `IdmtRole` rows. +- Data migration script (SQL + EF seed-adjust) for existing deployments. +- `CLAUDE.md` — doc alignment. + +--- + +## Verification + +- **Canonical schema**: unit test confirm `IdmtUser` *not* filtered by Finbuckle tenant query filter; `FindByEmailAsync` work across tenant contexts. +- **SysRole claim**: integration test — user with `SysRole = SysAdmin` authenticate in two different tenants; role claim present both times. +- **GrantTenantAccess create zero users**: integration test — `POST /admin/tenant-access/grant` for existing canonical user; assert `IdmtUser` row count unchanged; `TenantAccess` row added. +- **GrantTenantAccess atomicity**: integration test — simulate `SaveChangesAsync` failure after `TenantAccess` addition; assert no partial state persist. +- **Self-grant guard** (from C2): already covered Phase 0 testing; re-run. +- **Revocation coherence** (N1): integration test — canonical user has tenant A + tenant B access; revoke via `RevokeTenantAccess` for tenant A; assert bearer token issued in tenant B still active until next Phase 2 revocation fix. Test document that revocation now coherent by `userId` alone (no shadow-row mismatch), but bearer-token enforcement itself land Phase 2. +- **C7 email-change OOB**: integration test — `PUT /manage/info` with new email → assert `IdmtUser.Email` unchanged, `IdmtUser.PendingEmail` set, confirmation email dispatched; `POST /auth/confirm-email-change` with valid token → `Email` committed, `PendingEmail` cleared. +- **C7 forgot-password on pending email**: integration test — stage new email; `POST /auth/forgot-password` with new email → 404 or no-op (pending email not the identity); legitimate `forgot-password` on current `Email` still works. +- **C7 reset no flip EmailConfirmed**: integration test — user with `EmailConfirmed = false`; successful password reset; assert `EmailConfirmed` still `false`. +- **C3 body `TenantIdentifier` gone**: contract test — `ConfirmEmailRequest` + `ResetPasswordRequest` have no `TenantIdentifier` property. +- **Data migration**: run script against test DB seeded with shadow rows; assert every `TenantAccess.UserId` resolve to extant `IdmtUser`; duplicate `IdmtUser` rows removed; `SysRole` correct backfilled. +- `dotnet test Idmt.slnx` pass. +- `dotnet format Idmt.slnx --verify-no-changes` pass. +- Build with warnings-as-errors pass. + +--- + +## Phase 1 done-criteria + +- `IdmtUser` global (no Finbuckle tenant filter); `SysRole` column present + populated. +- `GrantTenantAccess` no create `IdmtUser` rows; all writes in one transaction. +- `RevokeTenantAccess` operate on canonical `UserId`. +- `ConfirmEmail` / `ResetPassword` resolve tenant from request context. +- `ResetPassword` no mutate `EmailConfirmed`. +- Email-change flow out-of-band (new email not committed until confirmation). +- CLAUDE.md accurately reflect new data model. +- EF + data migrations exist + test-run against seeded shadow-row DB. +- Full test suite + format + warnings-as-errors pass. + +Phase 2 may begin when all above satisfied. \ No newline at end of file diff --git a/SECURITY_PHASE_2_BEARER_COHERENCE.md b/SECURITY_PHASE_2_BEARER_COHERENCE.md new file mode 100644 index 0000000..18b03b9 --- /dev/null +++ b/SECURITY_PHASE_2_BEARER_COHERENCE.md @@ -0,0 +1,313 @@ +# Phase 2 — Bearer-Token Coherence + +Make revocation enforceable. Depend on Phase 1 (canonical identity) — revocation key on canonical `userId`. + +--- + +## Project overview + +IDMT (Identity MultiTenant) Plugin — reusable NuGet lib for ASP.NET Core, multi-tenant identity. Built on Finbuckle.MultiTenant + ASP.NET Core Identity. Per-tenant cookie isolation, hybrid cookie/bearer auth, vertical slice arch. Use ErrorOr for results, FluentValidation for requests. Target: net10.0. + +Key services + concepts: +- **Finbuckle.MultiTenant** resolve tenants via strategies (Header, Route, Claim, BasePath). +- `IdmtUser` global (post-Phase-1); `IdmtRole` per-tenant; `SysRole` global enum column on `IdmtUser`. +- `TenantAccess` map users to tenants with `IsActive` + optional `ExpiresAt`. +- Per-tenant cookie isolation: each tenant separate auth cookie name. +- `ValidateBearerTokenTenantMiddleware` ensure bearer token tenant match request tenant. +- Two EF contexts: `IdmtDbContext` (multi-tenant app data) + `IdmtTenantStoreDbContext` (tenant metadata). +- Pre-configured auth policies: `RequireSysAdmin`, `RequireSysUser`, `RequireTenantManager`, `CookieOnly`, `BearerOnly`. +- Token revocation via `ITokenRevocationService` + background cleanup (`TokenRevocationCleanupService`). +- Bearer auth use `AddBearerToken` with DataProtection-based opaque tokens (not JWT). + +Build/test: `dotnet build Idmt.slnx`, `dotnet test Idmt.slnx`, `dotnet format Idmt.slnx --verify-no-changes`. + +--- + +## Architectural context (from Phase 1) + +**Canonical `IdmtUser` + `TenantAccess` + global `SysRole` column.** + +Phase 1 made `IdmtUser` global. One hash, one stamp, one canonical `Id` per human. Revocation keyed by `(userId, tenantId)` now resolve unambiguously — single revocation entry for user X in tenant Y covers every bearer token issued to that user for that tenant. + +Phase 2 build on this: revocation store coherent, but *not consulted* on access-token use (only on refresh). That core gap closed this phase. + +### Target schema (relevant portion, carried from Phase 1) + +``` +IdmtUser (global — no IsMultiTenant) + Id, Email, PasswordHash, SecurityStamp, LockoutEnd, IsActive, + SysRole : SysRoleKind (None | SysAdmin | SysSupport, default None) + +RevokedToken (or equivalent revocation record) + UserId, TenantId, RevokedAt + (Stores the "everything issued to this user/tenant before RevokedAt is invalid" marker.) +``` + +### Claim factory (carried from Phase 1) + +```csharp +var roles = await userManager.GetRolesAsync(user); // per-tenant, Finbuckle-filtered +foreach (var role in roles) identity.AddClaim(new Claim(ClaimTypes.Role, role)); +if (user.SysRole != SysRoleKind.None) + identity.AddClaim(new Claim(ClaimTypes.Role, user.SysRole.ToString())); +``` + +--- + +## Phase 2 scope + +Five findings, all on bearer/refresh-token revocation + timestamp correctness: + +1. **M2** — `IssuedUtc` set explicitly on issued tickets. Prereq for rest of phase. +2. **N5** — Refresh-token rotation: presented refresh token invalidated on use; fresh refresh token issued. +3. **C1** — Access tokens check revocation store via `BearerTokenEvents.OnTokenValidated`. +4. **C5** — Remove dead null-tenant guard in `RefreshToken`. +5. **C6** — `Logout` return Unauthorized instead of silent 204 when tenant unresolvable. + +**Ship order matters**: M2 → N5 → C1 → C5/C6. M2 first because N5 and C1 rely on `IssuedUtc`. N5 before C1 so refresh tokens already rotated when access-token revocation tightens. + +--- + +## Finding M2 (Medium → ship first) — Refresh/auth `IssuedUtc` unset, revocation check drifts + +### Files +- `Idmt.Plugin/Features/Auth/RefreshToken.cs:70-77` +- `Idmt.Plugin/Features/Auth/Login.cs:290-297` + +### Detail +`AuthenticationProperties` built for bearer + refresh issuance don't set `IssuedUtc`. Revocation check in `RefreshToken.HandleAsync:74` (+ new C1 check) compare token `IssuedUtc` vs store `RevokedAt`. When `IssuedUtc` unset, code fall back to `issuedAt = expiresUtc - RefreshTokenExpiration` (or equivalent for access tokens). + +Failure mode: operator shorten `RefreshTokenExpiration` (or `BearerTokenExpiration`) after issuing tokens → computed `issuedAt` for older tokens become *later* than true issuance. Tokens issued before revocation compute false `issuedAt >= RevokedAt` → revocation check pass → token accepted despite revoked. + +### Fix +Set `IssuedUtc = timeProvider.GetUtcNow()` explicit on *both* auth ticket + refresh ticket properties in `TokenLoginHandler` (login) and on newly-issued properties during refresh rotation (N5). + +```csharp +var now = timeProvider.GetUtcNow(); +var authProperties = new AuthenticationProperties +{ + IssuedUtc = now, + ExpiresUtc = now.Add(options.BearerTokenExpiration), + // ... +}; +var refreshProperties = new AuthenticationProperties +{ + IssuedUtc = now, + ExpiresUtc = now.Add(options.RefreshTokenExpiration), + // ... +}; +``` + +Remove fallback path in revocation check. If `IssuedUtc` missing on incoming ticket, reject as invalid — don't compute from expiry. + +### Files to modify +- `Idmt.Plugin/Features/Auth/Login.cs` (`TokenLoginHandler`) +- `Idmt.Plugin/Features/Auth/RefreshToken.cs` +- `Idmt.Plugin/Services/TokenRevocationService.cs` (remove fallback computation) + +### Verification +- Unit test: decode fresh ticket; assert `IssuedUtc` set. +- Unit test: `IsTokenRevokedAsync` called with ticket whose `IssuedUtc` null → return "invalid" (not false-negative). +- Unit test: after `TimeProvider` advance 5 min, `IssuedUtc` reflect original issuance time, not now. + +### Dependencies +None within Phase 2. Ship first. M2 prereq for N5 + C1. + +--- + +## Finding N5 (High) — Refresh-token rotation absent + +### File +`Idmt.Plugin/Features/Auth/RefreshToken.cs:41-81` + +### Detail +Current refresh flow validate presented refresh token, check expiry, optionally check revocation (only when `tenantId is not null`), issue new access token. Does **not** issue new refresh token, does **not** invalidate presented refresh token. Same refresh token replayable until absolute expiration (default 14 days). + +Impact: stolen refresh token reusable for full lifetime window. C1 access-token revocation half-fix without rotation — attacker simply refresh when access token rejected. + +### Fix +On every successful refresh: +1. Issue **new refresh token** alongside new access token. Include `IssuedUtc` per M2. +2. **Mark presented refresh token used**. Options: + - **Option A (recommended)**: store presented ticket `IssuedUtc` in revocation store keyed `(userId, tenantId)` — raise per-tenant revocation point above old `IssuedUtc` but below new one. Coarse (revokes *all* tokens issued before new one), simple with existing schema. Acceptable — new refresh token supersede all prior anyway. + - **Option B**: introduce token-id (`jti`-equivalent) into ticket payload + track revoked-id set. Heavier — extend protected payload. Defer later phase. +3. Detect **refresh-token reuse**: if presented refresh `IssuedUtc < current per-user-tenant revocation point`, treat as compromised-refresh event. Revoke all user tokens in tenant (`RevokeUserTokensAsync`) + log security event. Standard rotated-refresh reuse-detection pattern. + +Start Option A. If finer tracking needed later, layer Option B. + +### Files to modify +- `Idmt.Plugin/Features/Auth/RefreshToken.cs` +- `Idmt.Plugin/Services/TokenRevocationService.cs` (optional: expose `SetRevocationPointAsync(userId, tenantId, utc)` helper distinct from `RevokeUserTokensAsync`). + +### Verification +- Integration test: present refresh token twice → second call 401. +- Integration test: present refresh, receive new refresh, present new → 200. +- Integration test: present refresh, receive new, present *old* → 401 **and** newly-issued refresh also revoked (reuse detection). +- Integration test: `IssuedUtc` of new refresh > old refresh. + +### Dependencies +M2 ship first. + +--- + +## Finding C1 (Critical) — Access tokens never checked against revocation store + +### Files +- `Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs` +- `Idmt.Plugin/Services/TokenRevocationService.cs` +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:370-384` (`BearerTokenEvents` configuration) + +### Detail +`IsTokenRevokedAsync` called only inside `RefreshToken.HandleAsync:74`. No middleware, auth event, or policy check revocation for plain access-token requests. After logout / password reset / revoke-tenant-access, previously-issued bearer access token keep working until natural expiration (`BearerTokenExpiration`, default 60 min). + +**Attack**: stolen bearer token survive logout + password reset up to 60 min. Combined with N5 (refresh rotation), closing this gap mean stolen credentials lose access within access-token window after any revocation. + +### Fix +Wire `BearerTokenEvents.OnTokenValidated` to call `IsTokenRevokedAsync` after ticket unprotected + principal available. Reject on revocation hit. + +```csharp +.AddBearerToken(IdmtAuthOptions.BearerScheme, options => +{ + options.Events = new BearerTokenEvents + { + OnTokenValidated = async ctx => + { + var userId = ctx.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + var tenantClaim = ctx.Principal?.FindFirstValue(IdmtClaims.Tenant); + var issuedUtc = ctx.Properties?.IssuedUtc; + + if (userId is null || tenantClaim is null || issuedUtc is null) + { + ctx.Fail("Invalid token ticket"); + return; + } + + var revocationSvc = ctx.HttpContext.RequestServices + .GetRequiredService(); + if (await revocationSvc.IsTokenRevokedAsync( + Guid.Parse(userId), tenantClaim, issuedUtc.Value)) + { + ctx.Fail("Token revoked"); + } + } + }; + // other configuration ... +}); +``` + +**Caching**: add short-TTL (~30s) in-memory cache keyed `(userId, tenantId)` to bound DB load. Cache revocation point (`RevokedAt` timestamp). On cache hit with stale entry, compare vs token `IssuedUtc` without DB roundtrip. Invalidate on new revocations written by `RevokeUserTokensAsync`. + +**Alternative**: middleware between `UseAuthentication` + `UseAuthorization` doing same check. `OnTokenValidated` preferred — fails early (within auth handler), participates natively in auth result pipeline. + +### Files to modify +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — extend `AddBearerToken` config with `OnTokenValidated`. +- `Idmt.Plugin/Services/TokenRevocationService.cs` — add cache layer. +- Potentially new file: `Idmt.Plugin/Services/TokenRevocationCache.cs` (memory cache for revocation points). + +### Verification +- Integration test: login, call protected endpoint → 200; logout; call same endpoint with cached bearer → 401. +- Integration test: login tenant A, revoke tenant-access for user in A, call protected A endpoint with cached bearer → 401. +- Integration test: login tenant A, issue two tokens at different moments; revoke user; both rejected. +- Integration test: cache behavior — revoke user; wait < TTL for cache invalidation propagation; assert token rejected within TTL. +- Concurrency test: logout race — concurrent logout + protected-endpoint call; expected either one succeeds and other fails, never both succeed. Exercise `DbUpdateException` branch at `TokenRevocationService.cs:43`. +- Clock-skew test: revoke at T, token `IssuedUtc = T - 1ms`, verify revoked (exercise L5 `<` boundary — confirm comparison strictly `IssuedUtc < RevokedAt`, meaning token issued *after* revocation honored). + +### Dependencies +- M2 (IssuedUtc set) ship first. +- N5 (refresh rotation) ship first. Without rotation, C1 force attackers to call refresh; close both together close loop. + +--- + +## Finding C5 (demoted) — Remove dead null-tenant guard in `RefreshToken` + +### File +`Idmt.Plugin/Features/Auth/RefreshToken.cs:62-67, 72-76` + +### Detail +`RefreshToken.cs:62-67` already return `Unauthorized` on null tenant (from token claim or current resolved tenant). `if (tenantId is not null && await IsTokenRevokedAsync(...))` guard at line 72 dead — `tenantId` can't be null there. + +Originally Critical (null-tenant revocation bypass); evidence show earlier check already 401s. Reclassified hygiene. + +### Fix +Remove `tenantId is not null &&` conjunction. Call `IsTokenRevokedAsync` unconditional. If something slip past earlier guard (refactor), call should throw or return definitive answer — never silently skip. + +### Files to modify +- `Idmt.Plugin/Features/Auth/RefreshToken.cs` + +### Verification +- Code review confirm dead guard removed. +- Integration test: unreachable-by-design, but assert `RefreshToken` return 401 when `X-Tenant` header missing (pre-existing behavior, regression). + +### Dependencies +Can ship with C1 — both touch `RefreshToken`. + +--- + +## Finding C6 (demoted to Medium) — `Logout` silent-success on null tenant + +### File +`Idmt.Plugin/Features/Auth/Logout.cs:46-79` + +### Detail +Bearer-authenticated logout with no resolvable tenant context hit `else` branch (line 69-79) — logs warning, return 204 **without calling `RevokeUserTokensAsync`**. Refresh tokens stay valid. Current code reach this branch only under cookie auth with null tenant — cookies per-tenant-named so path narrow, but fail-closed > fail-open. + +### Fix +Return `IdmtErrors.Auth.Unauthorized` (or new `IdmtErrors.Tenant.NotResolved` → 401/400 per convention) instead of 204. Never succeed logout that didn't revoke. Remove silent-warn branch; log event as error if it fires post-Phase-1. + +### Files to modify +- `Idmt.Plugin/Features/Auth/Logout.cs` +- Potentially `Idmt.Plugin/Errors/IdmtErrors.cs` if new error code introduced. + +### Verification +- Integration test: authenticated request with no resolvable tenant → `/auth/logout` return 401, not 204. +- Integration test: normal authenticated logout path → 204 + refresh tokens revoked (regression). + +### Dependencies +None; ship alongside C5. + +--- + +## Phase 2 implementation order + +1. **M2** — set `IssuedUtc` explicit. Unit tests. +2. **N5** — refresh-token rotation with reuse detection. Integration tests. +3. **C1** — `OnTokenValidated` revocation check with short-TTL cache. Integration + concurrency tests. +4. **C5** — remove dead null-tenant guard. Code-review verification. +5. **C6** — fail-closed logout. Integration test. + +Split two-three PRs if helpful: PR#1 = M2 + N5, PR#2 = C1, PR#3 = C5 + C6. Each build on prior. + +--- + +## Files to modify (summary) + +- `Idmt.Plugin/Features/Auth/Login.cs` — set `IssuedUtc` at token issuance (M2). +- `Idmt.Plugin/Features/Auth/RefreshToken.cs` — set `IssuedUtc`, implement rotation + reuse detection, remove dead guard (M2, N5, C5). +- `Idmt.Plugin/Features/Auth/Logout.cs` — fail-closed on null tenant (C6). +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — wire `OnTokenValidated` handler (C1). +- `Idmt.Plugin/Services/TokenRevocationService.cs` — remove fallback, add cache integration, optionally add `SetRevocationPointAsync` for rotation (M2, N5, C1). +- `Idmt.Plugin/Services/TokenRevocationCache.cs` — new in-memory cache for recent revocation points (C1). +- `Idmt.Plugin/Errors/IdmtErrors.cs` — optionally add `Tenant.NotResolved` (C6). + +--- + +## Verification (phase-wide) + +- All unit/integration tests above pass. +- Regression: full Phase 1 test suite (canonical identity) still pass — revocation coherent across tenants for single canonical user. +- `dotnet test Idmt.slnx` passes. +- `dotnet format Idmt.slnx --verify-no-changes` passes. +- Build with warnings-as-errors passes. + +--- + +## Phase 2 done-criteria + +- All bearer tickets carry explicit `IssuedUtc`; revocation service reject tickets without one. +- Present same refresh token twice → second call rejected; reuse trigger full-user revocation in tenant. +- Protected endpoints with revoked bearer token return 401. +- `RefreshToken.cs` contain no conditional revocation check. +- `Logout` return 401 (not 204) when tenant unresolvable; success path revoke tokens. +- Full test suite + format + warnings-as-errors pass. + +Phase 3 begin when all above satisfied. \ No newline at end of file diff --git a/SECURITY_PHASE_3_MIDDLEWARE_CONFIG.md b/SECURITY_PHASE_3_MIDDLEWARE_CONFIG.md new file mode 100644 index 0000000..8346639 --- /dev/null +++ b/SECURITY_PHASE_3_MIDDLEWARE_CONFIG.md @@ -0,0 +1,426 @@ +# Phase 3 — Middleware + Config Hardening + +Pipeline order, URL validation, rate limit, strong defaults, DP key ring enforce. Depend Phase 0-2. + +--- + +## Project overview + +IDMT (Identity MultiTenant) Plugin — reusable NuGet lib for ASP.NET Core. Multi-tenant identity mgmt. Built on Finbuckle.MultiTenant + ASP.NET Core Identity. Per-tenant cookie isolation, hybrid cookie/bearer auth, vertical slice arch. ErrorOr for results, FluentValidation for requests. Target: net10.0. + +Key services/concepts: +- **Finbuckle.MultiTenant** resolve tenants via strategies (Header, Route, Claim, BasePath). +- `IdmtUser` global (post-Phase-1); `IdmtRole` per-tenant; `SysRole` global enum on `IdmtUser`. +- `TenantAccess` map users to tenants w/ `IsActive` + optional `ExpiresAt`. +- Per-tenant cookie isolation: each tenant gets separate auth cookie name. +- `ValidateBearerTokenTenantMiddleware` ensures bearer token tenant match request tenant. +- Two EF contexts: `IdmtDbContext` (multi-tenant app data) + `IdmtTenantStoreDbContext` (tenant metadata). +- Pre-configured auth policies: `RequireSysAdmin`, `RequireSysUser`, `RequireTenantManager`, `CookieOnly`, `BearerOnly`. +- Token revocation via `ITokenRevocationService` (now enforced on access-token validation post-Phase-2). +- Bearer auth use `AddBearerToken` w/ DataProtection opaque tokens. + +Build/test: `dotnet build Idmt.slnx`, `dotnet test Idmt.slnx`, `dotnet format Idmt.slnx --verify-no-changes`. + +--- + +## Architectural context (carried from Phase 1) + +**Canonical `IdmtUser` + `TenantAccess` + global `SysRole` column.** + +`IdmtUser` global (not per-tenant). One canonical `Id` per human. Revocation keyed by `(userId, tenantId)` coherent across tenants. `SysRole` non-nullable enum (`None | SysAdmin | SysSupport`), emit as role claim at login when `!= None`. Sys users reach any tenant w/o `TenantAccess` row; normal cross-tenant access still need `TenantAccess` + per-tenant `IdentityUserRole`. + +Phase 2 wired `OnTokenValidated` to call `IsTokenRevokedAsync` for access tokens, implemented refresh-token rotation w/ reuse detection, made `Logout` fail-closed on null tenant. + +--- + +## Phase 3 scope + +Seven finding clusters — middleware pipeline order, config validation, rate limit, defaults, Data Protection: + +1. **H3** — Middleware order: tenant-validation runs before `UseAuthorization`. +2. **H4** — `ClientUrl` validate HTTPS + absolute + root path at startup. +3. **H2** — Rate limit on by default w/ distinct per-endpoint policies. +4. **H1 + N4** — `DiscoverTenants` gated, always rate-limited, fixed-shape response, kill response-length oracle. +5. **N6** — `ForgotPassword` per-email throttle. +6. **M9, M10, M11, M12, M13** — config validator throw on `SameSite=None` rewrites; no default email stub; tenant identifier char-class validation; stronger password defaults; shorter token/cookie defaults. +7. **N8** — Data Protection key ring persistence required at startup in non-dev envs. + +Order in phase: H3 first (pipeline), then config-validator tighten (H4, M9, M10, M11, M12, M13, N8), then rate limit (H2, N6, H1, N4). H3 safer to ship isolated; config tighten may surface consumer misconfigs needing coordinated rollout. + +--- + +## Finding H3 (architectural smell — ship for correctness) — Middleware order + +### File +`Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs:50-59` + +### Current pipeline +`UseMultiTenant → UseAuthentication → UseAuthorization → ValidateBearerTokenTenantMiddleware → CurrentUserMiddleware` + +### Problem +Authz policies evaluate principal *before* tenant validation. Current policies (`ServiceCollectionExtensions.cs:426-438`) pure `RequireRole(...)`, don't read tenant-scoped services, so no exploit today. But consumer adding custom authz handler reading `ICurrentUserService` or other tenant-scoped services would see mismatched state. Defensive fix. + +### Fix +Reorder to: `UseMultiTenant → UseAuthentication → ValidateBearerTokenTenantMiddleware → CurrentUserMiddleware → UseAuthorization`. + +Tenant validation + current-user population happen *between* authentication and authorization so all authz handlers see coherent tenant-scoped view. + +### Files to modify +- `Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs` + +### Verification +- Integration test: custom authz handler registered by test harness reads resolved tenant; cross-tenant bearer token rejected *before* handler runs. +- Regression: all existing auth/authz tests pass. + +### Dependencies +None in Phase 3; ship first. + +--- + +## Finding H4 (High) — `ClientUrl` validation + +### Files +- `Idmt.Plugin/Services/IdmtLinkGenerator.cs:91-108` +- `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs:39-45` + +### Problem +Validator only checks `ClientUrl` non-empty. Not required absolute, HTTPS, or rooted at `/`. Password-reset + confirm-email links embed tokens in URL query string; misconfigured or poisoned `ClientUrl` send token to attacker-controlled host. + +### Fix +In `IdmtOptionsValidator`, require: +- `Uri.IsWellFormedUriString(url, UriKind.Absolute)` — must be absolute. +- `scheme == Uri.UriSchemeHttps` — must be HTTPS (allow `http` only when new explicit option `Application.AllowInsecureClientUrl = true` set). +- `uri.AbsolutePath == "/"` — no path segments (client routes appended by `IdmtLinkGenerator`). +- Host must be non-empty. + +Fail fast at startup w/ actionable error message. + +### Files to modify +- `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs` +- `Idmt.Plugin/Configuration/IdmtOptions.cs` — add `Application.AllowInsecureClientUrl` flag (default false). + +### Verification +- Startup test: `ClientUrl = "http://evil.com/path"` → options validation throws. +- Startup test: `ClientUrl = "https://app.example.com/"` → passes. +- Startup test: `ClientUrl = "not-a-url"` → fails. +- Startup test: `ClientUrl = "http://localhost:5000/"` + `AllowInsecureClientUrl = true` → passes (dev scenario). + +### Dependencies +None; ship w/ other config-validator tightening. + +--- + +## Finding H2 (High) — Rate limiting disabled by default + +### Files +- `Idmt.Plugin/Configuration/IdmtOptions.cs:310` (`RateLimitingOptions.Enabled = false`) +- `Idmt.Plugin/Features/AuthEndpoints.cs:30-33` + +### Problem +Account lockout (5 attempts / 5 min, per-user) does not protect against: +- Credential stuffing across *different* accounts. +- `/forgot-password` spam (mailstorm + token churn). +- `/resend-confirmation-email` spam. +- `/discover-tenants` enumeration. + +Default `Enabled = false` means consumers get zero rate limit out of box. + +### Fix +1. Flip default: `RateLimitingOptions.Enabled = true`. Consumers fronting app w/ own limiter (Cloudflare, reverse proxy) can explicitly opt out. +2. Define distinct per-endpoint policies: + - `/auth/login`: 20 requests / minute / IP. + - `/auth/token` (refresh): 60 requests / minute / IP (legit clients refresh often). + - `/auth/forgot-password`: 5 requests / minute / IP (plus N6 per-email throttle). + - `/auth/discover-tenants`: 10 requests / minute / IP (always-on, see H1/N4). + - `/auth/resend-confirmation-email`: 5 requests / minute / IP. +3. Policies apply regardless of `Enabled` flag when endpoint security-sensitive (discover-tenants, forgot-password, resend-confirmation). "Enabled = false" is consumer override for login/refresh policies only; discovery-class endpoints always enforce limit. + +### Files to modify +- `Idmt.Plugin/Configuration/IdmtOptions.cs` — flip default; define per-endpoint policy knobs. +- `Idmt.Plugin/Features/AuthEndpoints.cs` — attach rate-limiter policies to each endpoint. +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — register rate-limiter services. + +### Verification +- Integration test: 25 login attempts in 60s → last 5 return 429. +- Integration test: 6 forgot-password calls in 60s → last call returns 429 (regardless of `Enabled` flag). +- Contract test: `RateLimitingOptions.Enabled` default = `true`. + +### Dependencies +None in Phase 3. + +--- + +## Finding H1 + N4 (High) — `DiscoverTenants` enumeration oracle + +### Files +- `Idmt.Plugin/Features/Auth/DiscoverTenants.cs:42-88,99-122` +- `Idmt.Plugin/Configuration/IdmtOptions.cs:310` (RateLimiting) +- `Idmt.Plugin/Features/AuthEndpoints.cs:30-33` + +### Problem +Unauthenticated `POST /auth/discover-tenants` returns list of `(Identifier, Name)` tuples for known emails, empty array for unknown. Response shape (Content-Length) is oracle: attackers enumerate valid emails + tenant membership by comparing payload sizes, bypass timing-only protections. + +### Fix +1. Gate endpoint behind explicit `Auth.AllowTenantDiscovery` option (default **false**). Consumers needing feature opt in. +2. When enabled, always attach per-endpoint rate limiter regardless of global `RateLimiting.Enabled`. +3. Equalize response timing: perform same work regardless of email known (dummy query, constant-time response construction). +4. Equalize response shape: return fixed-shape payload for both known + unknown emails. Options: + - Return opaque blob (e.g., HMAC-signed nonce), deliver real tenant list via email to address (out-of-band, no oracle). + - Return fixed-length array padded w/ placeholder entries, client matches against claim at login. + - Return consistent "request queued, check your email" 202 response every call. + Prefer email-delivery: strongest guarantee, matches how account-recovery flows typically work. +5. If endpoint returns tuples at all, return only tenant IDs (not names) — names leak org info. + +### Files to modify +- `Idmt.Plugin/Features/Auth/DiscoverTenants.cs` — full rewrite of handler + response shape. +- `Idmt.Plugin/Configuration/IdmtOptions.cs` — add `Auth.AllowTenantDiscovery` (default false). +- `Idmt.Plugin/Features/AuthEndpoints.cs` — conditional mapping based on feature flag; always attach limiter. +- `Idmt.Plugin/Services/IdmtEmailSender.cs` — new email template for "tenant discovery result" (if email-delivery path). + +### Verification +- Integration test: feature flag off → endpoint returns 404 / not mapped. +- Integration test: feature flag on + unknown email + known email → identical HTTP status, identical Content-Length, timing within ±10 ms. +- Integration test: 11 calls in 60s → at least one 429 (always-on limiter). +- Integration test: known email → email dispatched w/ tenant list. + +### Dependencies +H2 rate-limiter infrastructure must exist (may ship together). + +--- + +## Finding N6 (High) — `ForgotPassword` per-email throttle + +### File +`Idmt.Plugin/Features/Auth/ForgotPassword.cs:42-58` + +### Problem +Every unauthenticated `/auth/forgot-password` call triggers Identity token generation + email dispatch. Global per-IP rate limit (H2) not prevent attacker rotating IPs to flood reset emails for specific target — burying legit reset messages, exhausting mail-provider quotas. + +### Fix +Add per-email sliding-window throttle on top of global per-IP limiter. Suggested default: **1 request / 5 minutes / email**. Store in existing `IdmtDbContext` (new table `EmailThrottle` w/ `EmailNormalized`, `LastAttemptUtc`, `AttemptCount`) or use distributed cache abstraction consumers can configure (IMemoryCache for single-instance; IDistributedCache for scale-out). + +Respond identically whether throttled or not (don't leak throttle state to attackers). + +### Files to modify +- `Idmt.Plugin/Features/Auth/ForgotPassword.cs` — consult throttle before work. +- New: `Idmt.Plugin/Services/IEmailThrottleService.cs` + default impl. +- `Idmt.Plugin/Configuration/IdmtOptions.cs` — `Auth.ForgotPasswordThrottle.Window = TimeSpan.FromMinutes(5)`, `.MaxPerWindow = 1`. +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — register throttle service. + +### Verification +- Integration test: 2 forgot-password calls for same email within 5 min → second call returns 200 (uniform) but **no email dispatched**. +- Integration test: 2 calls for different emails within 5 min → both dispatched. + +### Dependencies +None; ship parallel to H2. + +Also handles **H8** in passing: replace hand-rolled 3-char mask in `ForgotPassword.cs:62-64` w/ `PiiMasker.MaskEmail(request.Email)` while editing. + +--- + +## Finding M9 (Medium) — Cookie `SameSite=None` silently rewritten + +### File +`Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:333-335` + +### Problem +Consumer configures `SameSite=None` for legit cross-site flows. Current code silently rewrites to `Strict`. Cross-site flow then breaks invisibly. + +### Fix +In `IdmtOptionsValidator`, throw at startup w/ actionable message when `SameSite=None` configured: explain CSRF implications, require `SecurePolicy=Always`, reject if those not set together. Never mutate consumer config. + +### Files to modify +- `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs` +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — remove silent rewrite. + +### Verification +- Startup test: `SameSite=None, SecurePolicy=Always` → passes (explicit opt-in). +- Startup test: `SameSite=None, SecurePolicy=SameAsRequest` → throws w/ explanatory message. + +--- + +## Finding M10 (Medium) — `IdmtEmailSender` stub registered by default + +### Files +- `Idmt.Plugin/Services/IdmtEmailSender.cs` +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:452` + +### Problem +Default stub email sender registered if consumer doesn't provide one. Warning logged at startup but app runs. Password-reset + email-confirm emails silently vanish in prod. + +### Fix +- Do not register default `IEmailSender`. +- Throw at startup if no registration exists. +- Provide opt-in `services.UseStubEmailSender()` for dev/test, clearly marked non-production. + +### Files to modify +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — remove default; add startup validation. +- `Idmt.Plugin/Services/IdmtEmailSender.cs` — keep as class consumers opt into, rename to e.g., `StubEmailSender`. +- `Idmt.Plugin/Extensions/*` — add `UseStubEmailSender()` extension method. + +### Verification +- Startup test: no `IEmailSender` registered → `AddIdmt()` throws. +- Startup test: `services.UseStubEmailSender()` → passes, warning logged. + +Also handles part of **M8**: route all email logging through `PiiMasker.MaskEmail`. + +--- + +## Finding M11 (Medium) — `IdmtTenantInfo.Identifier` character-class validation + +### Files +- `Idmt.Plugin/Models/IdmtTenantInfo.cs:17-20` +- `Idmt.Plugin/Validation/CreateTenantRequestValidator.cs` +- `Idmt.Plugin/Services/IdmtLinkGenerator.cs:26-65` + +### Problem +Identifier only length-validated (≥ 3). Values like `foo/admin/../` could inject path segments into emitted confirm/reset URLs via `IdmtLinkGenerator`. + +### Fix +Enforce `^[a-z0-9-]+$` (or consumer-configurable regex w/ safe default) in: +- `IdmtTenantInfo` constructor. +- `CreateTenantRequestValidator`. +- Reject URL-unsafe identifiers both at create time + whenever tenant resolved from request header. + +### Files to modify +- `Idmt.Plugin/Models/IdmtTenantInfo.cs` +- `Idmt.Plugin/Validation/CreateTenantRequestValidator.cs` + +### Verification +- Unit test: `IdmtTenantInfo.Create("bad/identifier")` throws. +- Unit test: `CreateTenantRequestValidator` rejects `"FOO"`, `"foo_bar"`, `"foo/bar"`, `""`. +- Integration test: `POST /admin/tenants` w/ invalid identifier → 400. + +--- + +## Finding M12 (Medium) — Password-policy defaults + +### File +`Idmt.Plugin/Configuration/IdmtOptions.cs:148-153` + +### Current defaults +- `RequiredLength = 8` +- Lowercase + uppercase + digit required; symbol not required. + +### Problem +Meets OWASP ASVS L1 but falls below NIST SP 800-63B (prefers ≥ 12 chars) and below typical enterprise defaults. + +### Fix +- Raise `RequiredLength` default to **12**. +- Keep existing character-class requirements; `RequireNonAlphanumeric = true` optional — NIST prefers length over class mandates, industry practice still requires symbol. Leave default length-first w/ classes as-is unless team prefers stricter. +- Expose `MaxFailedAccessAttempts` (currently hard-coded 5 at `ServiceCollectionExtensions.cs:298-300`) and `DefaultLockoutTimeSpan` (currently hard-coded 5 min) via `IdmtOptions.Password` or new `IdmtOptions.Lockout` section. + +### Files to modify +- `Idmt.Plugin/Configuration/IdmtOptions.cs` +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` + +### Verification +- Contract test: default `RequiredLength` = 12. +- Contract test: consumer can override `MaxFailedAccessAttempts` and `DefaultLockoutTimeSpan`. + +--- + +## Finding M13 (Medium) — Cookie + bearer expiration defaults amplify C1 + +### File +`Idmt.Plugin/Configuration/IdmtOptions.cs:198-199, 215` + +### Current defaults +- Cookie `ExpireTimeSpan = 14 days, SlidingExpiration = true`. +- `BearerTokenExpiration = 60 min`. + +### Problem +Before Phase 2 fixes, stolen bearer token valid 60 min post-revocation; stolen cookie up to 14 days. Phase 2 fixes make bearer revocation real-time, but generous defaults amplify impact of any future regression. + +### Fix +- Cookie `ExpireTimeSpan = 7 days` default. +- `BearerTokenExpiration = 5 min` default. W/ Phase 2's refresh-rotation + revocation-on-validate, short-lived access tokens harmless (UX preserved by fast refresh); stolen tokens have tiny window. +- `RefreshTokenExpiration` can stay 14 days (rotation + reuse detection make long refresh windows safe). + +### Files to modify +- `Idmt.Plugin/Configuration/IdmtOptions.cs` + +### Verification +- Contract test: defaults match new values. +- Integration test: protected endpoint accepts token within 5 min, rejects after (expiry). +- Integration test: refresh flow within 5-min window succeeds; access token rolls correctly. + +--- + +## Finding N8 (High) — Data Protection key ring not required + +### File +`Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` (documentation + optional validation) + +### Problem +Bearer tokens + cookies protected w/ DataProtection. Without persisted key ring, host restart rotates keys → all pre-rotation tokens fail to unprotect. Combined w/ M2 (pre-Phase-2 IssuedUtc missing), revocation fallback path drifts after rotation, making revocation checks compare inconsistent timestamps. Post-Phase-2 this specific drift gone (M2 fixed), but fundamental issue remains: scaled-out deployments rolling-restart individual instances get inconsistent key rings unless persisted shared keys configured. + +### Fix +1. Document requirement clearly in `AddIdmt()` XML docs. +2. At startup in non-Development envs, check whether `IDataProtectionProvider` has key-ring repository persisting to known-non-ephemeral backing store. Non-trivial to introspect reliably; simplest approach: + - Require consumer to call `services.AddDataProtection().PersistKeysToX(...).SetApplicationName(...)` before `AddIdmt`. + - In `AddIdmt`, record flag saying "DataProtection configured". If flag absent at validation time (non-Development), throw. + - Provide helper `services.AddIdmtDataProtectionDefault(configureKeyRing)` making happy path explicit. +3. In Development, allow default in-memory key ring w/ warning. + +### Files to modify +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — validate DP config at startup. +- `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs` — non-dev requirement. +- Documentation: `CLAUDE.md` + XML docs on `AddIdmt`. + +### Verification +- Startup test: `Production` env + no key-ring config → throws. +- Startup test: `Development` + no key-ring → warning logged, startup proceeds. +- Startup test: `Production` + explicit key-ring → passes. + +--- + +## Phase 3 implementation order + +1. **H3** — middleware reorder (one-line change). Safe, ship first. +2. **H4, M9, M10, M11, M12, M13, N8** — config-validator tighten + default adjustments. Batch into one PR; expect consumer rollout coordination b/c defaults change. +3. **H2 + N6 + H1 + N4** — rate-limit infra + per-endpoint policies + discovery-endpoint fix. Single PR: H2 lays rails; N6 + H1/N4 plug into rails. + +--- + +## Files to modify (summary) + +- `Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs` — pipeline reorder (H3). +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — remove default email stub (M10); remove SameSite rewrite (M9); register rate limiter (H2); validate DP config (N8); expose lockout options (M12). +- `Idmt.Plugin/Configuration/IdmtOptions.cs` — defaults for password, token expiry, rate limit, discovery feature flag, `AllowInsecureClientUrl`; lockout options (M12, M13, H1/N4, H2, H4). +- `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs` — URL validation (H4); SameSite validation (M9); DP validation (N8). +- `Idmt.Plugin/Models/IdmtTenantInfo.cs` — identifier regex (M11). +- `Idmt.Plugin/Validation/CreateTenantRequestValidator.cs` — identifier validator (M11). +- `Idmt.Plugin/Features/AuthEndpoints.cs` — rate-limiter policies per endpoint (H2); conditional discovery mapping (H1). +- `Idmt.Plugin/Features/Auth/DiscoverTenants.cs` — fixed-shape response, email delivery (H1, N4). +- `Idmt.Plugin/Features/Auth/ForgotPassword.cs` — per-email throttle (N6) + `PiiMasker` swap (H8). +- `Idmt.Plugin/Services/IdmtEmailSender.cs` — rename / split into `StubEmailSender` (M10). +- `Idmt.Plugin/Services/IEmailThrottleService.cs` — new service (N6). +- EF migration: new `EmailThrottle` table if persistent store chosen. + +--- + +## Verification (phase-wide) + +- All unit/integration tests under each finding pass. +- Regression: all existing auth/authz integration tests still pass after pipeline reorder. +- `dotnet test Idmt.slnx` passes. +- `dotnet format Idmt.slnx --verify-no-changes` passes. +- Build w/ warnings-as-errors passes. + +--- + +## Phase 3 done-criteria + +- Pipeline order: `UseMultiTenant → UseAuthentication → ValidateBearerTokenTenantMiddleware → CurrentUserMiddleware → UseAuthorization`. +- `ClientUrl` rejected at startup if not absolute HTTPS w/ `Path == "/"` (or explicit `AllowInsecureClientUrl` flag). +- Rate limit on by default; per-endpoint policies enforced; discovery + forgot-password + resend-confirmation endpoints always rate-limited. +- `DiscoverTenants` gated by feature flag, fixed-shape response, email-delivery mode. +- `ForgotPassword` per-email throttle enforced; PII mask consistent. +- No default stub `IEmailSender`; opt-in only. +- Tenant identifier char-class validated. +- Stronger password, cookie, bearer, lockout defaults. +- Non-dev startup requires persisted DP key ring. +- Full test suite + format + warnings-as-errors pass. + +Phase 4 may begin when all above satisfied. \ No newline at end of file diff --git a/SECURITY_PHASE_4_HYGIENE.md b/SECURITY_PHASE_4_HYGIENE.md new file mode 100644 index 0000000..bdc2d23 --- /dev/null +++ b/SECURITY_PHASE_4_HYGIENE.md @@ -0,0 +1,422 @@ +# Phase 4 — Hygiene + +Cleanup, smaller Mediums, Lows, plus two new High findings not structural (N7 audit coupling, N9 CSRF defense-in-depth). Depend on Phases 0-3. + +--- + +## Project overview + +IDMT (Identity MultiTenant) Plugin — reusable NuGet library for ASP.NET Core. Multi-tenant identity management. Built on Finbuckle.MultiTenant + ASP.NET Core Identity. Per-tenant cookie isolation, hybrid cookie/bearer auth, vertical slice architecture. ErrorOr for results, FluentValidation for requests. Target: net10.0. + +Key services + concepts: +- **Finbuckle.MultiTenant** resolve tenants via configurable strategies (Header, Route, Claim, BasePath). +- `IdmtUser` global (post-Phase-1); `IdmtRole` per-tenant; `SysRole` global enum on `IdmtUser`. +- `TenantAccess` map users to tenants with `IsActive` + optional `ExpiresAt`. +- Per-tenant cookie isolation: each tenant get separate auth cookie name. +- `ValidateBearerTokenTenantMiddleware` run between authentication + authorization (post-Phase-3). +- Two EF contexts: `IdmtDbContext` (multi-tenant app data) + `IdmtTenantStoreDbContext` (tenant metadata). +- Pre-configured auth policies: `RequireSysAdmin`, `RequireSysUser`, `RequireTenantManager`, `CookieOnly`, `BearerOnly`. +- Token revocation via `ITokenRevocationService`; access-token validation consult revocation (post-Phase-2). +- Bearer auth use `AddBearerToken` with DataProtection-based opaque tokens. Refresh tokens rotate per use. +- Rate limiting on by default with per-endpoint policies; tenant-discovery endpoint gated behind explicit feature flag (post-Phase-3). + +Build/test: `dotnet build Idmt.slnx`, `dotnet test Idmt.slnx`, `dotnet format Idmt.slnx --verify-no-changes`. + +--- + +## Architectural context (carried from Phase 1) + +**Canonical `IdmtUser` + `TenantAccess` + global `SysRole` column.** + +`IdmtUser` global (not per-tenant). One canonical `Id` per human. Revocation keyed by `(userId, tenantId)` coherent across tenants. `SysRole` non-nullable enum (`None | SysAdmin | SysSupport`) emit as role claim at login when `!= None`. Sys users reach any tenant without `TenantAccess` row; normal cross-tenant access still need `TenantAccess` + per-tenant `IdentityUserRole`. + +--- + +## Phase 4 scope + +Mostly mediums + lows plus two stragglers (N7, N9): + +- **H5** — Fake transaction boundary in `UpdateUserInfo`. +- **M1** — Login timing oracle (no dummy hash on null user). +- **M3** — `is_active` claim staleness; propagate deactivation via stamp update + revocation. +- **M4** — Handler lookups by `NameIdentifier` (canonical `Id`) instead of email. +- **M5** — Self-target / peer-rank guards on destructive user-management actions. +- **M6** — Endpoint-level `RequireAuthorization` defense-in-depth (mostly covered by Phase 0 C2 pass; finalize here). +- **M7** — `ResendConfirmationEmail` async email dispatch to kill side-channel timing oracle. +- **M8** — Sanitize Identity error descriptions in logs; consistent `PiiMasker` use. +- **N7** — Decouple audit log writes from business-data transaction; rethrow on audit failure for security-critical tables. +- **N9** — Antiforgery / `Origin` validation as CSRF defense-in-depth on cookie flows. +- Lows (**L2**–**L10**) — request-body size caps, `ConfirmEmail` GET mode docs, revoked-token cleanup startup delay, `<` vs `<=` docs, `ApiPrefix` validation, customizer regression docs, health-check exception leak, CLAUDE.md alignment (subsumed by Phase 1 mostly; verify). + +--- + +## Finding H5 (High) — Fake transaction boundary in `UpdateUserInfo` + +### File +`Idmt.Plugin/Features/Manage/UpdateUserInfo.cs:87-115` + +### Problem +Handler call `BeginTransactionAsync` around block that include `UserManager.ChangeEmailAsync`. `ChangeEmailAsync` issue own internal `SaveChangesAsync` — outer transaction not encompass it. If later step (e.g., password change or final `UpdateAsync`) fail and outer `RollbackAsync` run, email change already committed. + +Post-Phase-1, `UpdateUserInfo` stage new emails out-of-band (email change not commit until confirmation token presented). So H5 largely dissolve — no in-flight `ChangeEmailAsync` call during main handler. But other ops in `UpdateUserInfo` (password change, `UpdateAsync` for other fields) may still share misleading transaction scope. + +### Fix +- Remove outer `BeginTransactionAsync` entirely if handler no longer need atomicity across multiple Identity API calls. +- If transaction retained, ensure every op inside participate correctly with EF `DbContext.Database.BeginTransactionAsync`. +- Serialize ops so any side-effect (like Identity internal saves) land last, after all other mutations succeed. Document explicit that compound identity updates non-atomic at user-visible level. +- Remove false guarantee from method XML docs. + +### Files to modify +- `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs` + +### Verification +- Code review: no outer `BeginTransactionAsync` wrapping `UserManager` calls. +- Integration test: simulate password-change failure after email stage → `PendingEmail` unaffected (or fully staged if change-email meant to be second step). +- Regression: existing `UpdateUserInfo` happy-path tests still pass. + +--- + +## Finding M1 (Medium) — Login timing oracle + +### File +`Idmt.Plugin/Features/Auth/Login.cs:89-101, 207-220` (`Login.Handler` and `TokenLoginHandler`) + +### Problem +```csharp +if (user is null || !user.IsActive) return Unauthorized; +``` +Short-circuit *before* PBKDF2 verify step. Existing users pay ~100 ms hashing cost; unknown or inactive users return in few ms. Timing analysis distinguish valid/invalid accounts. + +### Fix +On null / inactive branch, do dummy hash verification to equalize timing: +```csharp +userManager.PasswordHasher.VerifyHashedPassword(new IdmtUser(), DummyHash, request.Password); +return Unauthorized; +``` +`DummyHash` pre-computed PBKDF2 hash stored as constant. Work comparable to real hash verify without exposing real user hash. + +Same fix in `TokenLoginHandler` for bearer login path. + +### Files to modify +- `Idmt.Plugin/Features/Auth/Login.cs` + +### Verification +- Unit test: `Login` with unknown email → timing within ±20 ms of login with known email + wrong password. +- Unit test: `TokenLoginHandler` equivalent. + +--- + +## Finding M3 (Medium) — `is_active` claim staleness + +### Files +- `Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs:26` +- `Idmt.Plugin/Services/CurrentUserService.cs:34` +- `Idmt.Plugin/Features/Manage/UpdateUser.cs` (deactivation path) + +### Problem +`is_active` claim stamped at login. Admin deactivate user via `UpdateUser` → existing tokens still carry `is_active = true`. Cookie re-validate stamp every 30 min; bearer used to not re-validate at all (fixed in C1/Phase 2), but deactivation event need to actively invalidate sessions. + +### Fix +In `UpdateUser` when `IsActive` flip `true → false`: +1. Call `userManager.UpdateSecurityStampAsync(appUser)` — invalidate cookie sessions. +2. Call `tokenRevocationService.RevokeUserTokensAsync(userId, tenantId)` — invalidate bearer sessions (Phase 2 ensure honored). Under canonical model, one call cover user across all tenants they active in (key is canonical `userId`). + +Same pattern in `RevokeTenantAccess.cs` where `IsActive` flip on `TenantAccess` should also trigger stamp + revocation. + +### Files to modify +- `Idmt.Plugin/Features/Manage/UpdateUser.cs` +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs` — ensure already covered in Phase 1; re-audit. + +### Verification +- Integration test: deactivate user; cached bearer token return 401 on next request. +- Integration test: deactivate user; cookie session rejected within 30 min (stamp re-validation interval). + +--- + +## Finding M4 (Medium) — Handler lookups by email + +### Files +- `Idmt.Plugin/Features/Manage/GetUserInfo.cs:35-44` +- `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs:47-53` + +### Problem +`FindByEmailAsync(user.FindFirstValue(ClaimTypes.Email))` — break identity correlation if email changed between token issuance and request. `NameIdentifier` (canonical `Id`) is stable lookup key. + +### Fix +Use `FindByIdAsync(user.FindFirstValue(ClaimTypes.NameIdentifier))`. Validate security stamp against claim stamp value post-lookup (standard Identity pattern). Reject if mismatch. + +### Files to modify +- `Idmt.Plugin/Features/Manage/GetUserInfo.cs` +- `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs` + +### Verification +- Integration test: issue token, change email via `UpdateUserInfo` flow, old token still resolve correct user via `Id` (not email). + +--- + +## Finding M5 (Medium) — Self-target / peer-rank destruction guards + +### Files +- `Idmt.Plugin/Features/Manage/UpdateUser.cs:31-56` +- `Idmt.Plugin/Features/Manage/UnregisterUser.cs:31-56` +- `Idmt.Plugin/Services/TenantAccessService.cs:42-60` (`CanManageUser`) + +### Problem +`CanManageUser` block TenantAdmin from touching SysAdmin/SysSupport but allow TenantAdmin to delete/deactivate another TenantAdmin or themselves. Self-destructive actions produce orphaned tenants; peer-rank destruction create DoS for fellow admins. + +### Fix +1. In every destructive action (`UpdateUser` when setting `IsActive = false`, `UnregisterUser`, `RevokeTenantAccess`), reject when `request.UserId == currentUserService.UserId`. Return `IdmtErrors.General.SelfTarget` (or equivalent). +2. For TenantAdmin-on-TenantAdmin in same tenant, require opt-in danger flag (`request.ConfirmPeerRank = true`) or double-sign pattern (second TenantAdmin approve). Simplest: boolean flag in request + audit op as `HighRiskAdminAction`. +3. SysAdmins keep ability to override tenant-admin conflicts; don't apply guard to sys operations. + +### Files to modify +- `Idmt.Plugin/Features/Manage/UpdateUser.cs` +- `Idmt.Plugin/Features/Manage/UnregisterUser.cs` +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs` +- `Idmt.Plugin/Services/TenantAccessService.cs` +- `Idmt.Plugin/Errors/IdmtErrors.cs` — add `General.SelfTarget`, `General.PeerRankDanger` (or similar). + +### Verification +- Integration test: TenantAdmin call `UnregisterUser` with own userId → 400 `SelfTarget`. +- Integration test: TenantAdmin A call `UnregisterUser` on TenantAdmin B (same tenant) → 400 `PeerRankDanger` unless `ConfirmPeerRank = true`. +- Integration test: SysAdmin override freely. + +--- + +## Finding M6 (Medium) — Endpoint-level `RequireAuthorization` defense-in-depth + +### Files +- `Idmt.Plugin/Features/Admin/CreateTenant.cs:132-159` +- `Idmt.Plugin/Features/Admin/GetAllTenants.cs` +- `Idmt.Plugin/Features/Admin/GetUserTenants.cs` +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs` +- `Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs` +- `Idmt.Plugin/Features/AdminEndpoints.cs:14` (group-level) + +### Problem +Group-level `.RequireAuthorization` is only guard on several endpoints. If future refactor map endpoint outside group, become anonymous. `DeleteTenant.cs:74` already apply policy at endpoint level — use as template. + +### Fix +Add `.RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy)` (or `RequireSysUserPolicy` for read endpoints) to every individual endpoint mapper. Redundant with group-level guard, intentional. + +Most covered by Phase 0 C2 implementation. Phase 4 finalize any remaining gaps discovered during review. + +### Files to modify +- Every endpoint mapper under `Idmt.Plugin/Features/Admin/*`. + +### Verification +- Contract test: enumerate all mapped endpoints under `/admin/*`; assert each has endpoint-level authorization metadata. + +--- + +## Finding M7 (Medium) — `ResendConfirmationEmail` timing/dispatch oracle + +### File +`Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs:39-67` + +### Problem +Return `Ok` regardless of user existence, but only dispatch email when user exist + active + unconfirmed. Response timing and downstream email traffic observable — attacker can tell whether account exist by timing differences or by watching their mail server incoming queue for honeypot address. + +### Fix +1. Enqueue email dispatch async (e.g., via background queue or `Task.Run`-safe equivalent) so response time uniform regardless of user state. +2. Rate-limit endpoint (covered by Phase 3 H2). +3. Optional: dispatch placeholder "request received" email even for non-existent users. Trade-offs (spam risk) — decide per team policy. + +### Files to modify +- `Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs` +- Optional: background email queue service. + +### Verification +- Integration test: request with known-unconfirmed email + request with unknown email → identical response time ±20 ms. +- Integration test: known-unconfirmed email → email dispatched (observed via mocked `IEmailSender`). + +--- + +## Finding M8 (Medium) — PII masker inconsistent + +### Files +- `Idmt.Plugin/Services/PiiMasker.cs:11-15` +- `Idmt.Plugin/Features/Manage/RegisterUser.cs:92, 100` +- `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs:74, 91` +- `Idmt.Plugin/Features/Admin/GrantTenantAccess.cs:196` +- `Idmt.Plugin/Services/IdmtEmailSender.cs:11, 17, 23` (after Phase 3 rename to `StubEmailSender`) + +### Problem +`IdentityError.Description` messages like `"Username 'foo@bar.com' is already taken."` logged verbatim. Stub email sender log unmasked emails. + +### Fix +- Log only `IdentityError.Code`, not `Description`, for errors that may echo input. +- Route all email logging through `PiiMasker.MaskEmail`. +- Audit every logger statement that take user-provided strings; mask or omit as appropriate. + +### Files to modify +- All files referenced above. + +### Verification +- Grep source for `logger.Log*(...)` calls referencing `IdentityError.Description`, `request.Email`, `user.Email` — assert each masked or replaced with `IdentityError.Code`. +- Unit test: provoke duplicate-username error; capture log output; assert raw email not appear. + +--- + +## Finding N7 (High) — Audit log coupled to business-data transaction + +### File +`Idmt.Plugin/Persistence/IdmtDbContext.cs:159-229` (`SaveChangesAsync` audit-building overrides) + +### Problem +Audit entries built inside same `SaveChangesAsync` transaction as business data. Two failure modes: +- **L1 (original)**: malformed audit entry cause whole build step to fail; code detach *all* audit entries and commit business data with zero audit — compliance risk (SOC2 CC7.2). +- **Coupling (new)**: valid but large/problematic audit entries can block legitimate business writes. + +Either way, audit durability tied to business-data durability in wrong direction. + +### Fix +1. Move audit writes to **separate transaction** or **append-only outbox**: + - Simplest: after `SaveChangesAsync` for business data complete successfully, write audits in second `SaveChangesAsync` (own transaction). Failures log + alert but don't roll back business write. + - Better: append to outbox table in same transaction as business data (so no loss) but process async into audit store. +2. **Per-entry try/catch** at build time: if one audit entry fail to construct, record `AuditEntry { Success = false, Error = ... }` rather than drop *all* audits. +3. **For security-critical tables** (`IdmtUser`, `TenantAccess`, `RevokedToken`), rethrow on audit-build failure — do NOT allow business write without corresponding audit row. Deliberate inversion: for these tables want fail-closed on audit. + +### Files to modify +- `Idmt.Plugin/Persistence/IdmtDbContext.cs` — restructure audit-build pipeline. +- Potentially new `AuditOutbox` DbSet if outbox path chosen. + +### Verification +- Unit test: inject audit builder that throw for one entry → other audits succeed, business write proceed. +- Unit test: inject audit builder that throw for `IdmtUser` entry → business write rejected. +- Integration test: simulate audit-store failure after business write succeed → business write persist, audit outbox retain pending entry. + +--- + +## Finding N9 (Medium) — `SameSite=Strict` not sole CSRF defense + +### File +`Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:328-335` + +### Problem +Plan relied on `SameSite=Strict` as CSRF mitigation for cookie flows. Not bulletproof: +- Safari builds before ~2024 had different default behaviors for unset SameSite. +- Extension-initiated requests may present same-site origin. +- Certain redirect chains and iframe scenarios circumvent `Strict`. + +For security library meant for broad use, relying on single browser-side control insufficient. + +### Fix +Pick one (or both): +1. **Add `IAntiforgery`** as defense-in-depth for cookie state-changing flows. Issue antiforgery tokens on login; require on state-changing POST/PUT/DELETE endpoints when authed via cookie. Bearer flows exempt (tokens not sent automatically by browsers). +2. **Validate `Origin` / `Referer` headers** on state-changing cookie requests. Reject requests whose `Origin` host doesn't match configured `ClientUrl` host. + +Minimum viable: (2), lower friction for consumers. If team want stronger guarantee, layer (1) on top. + +Document clear: "IDMT cookie auth assume browser-only, same-origin usage. Consumers deploying cookie auth to cross-origin flows must enable `SameSite=None` + `AllowInsecureClientUrl=false` + confirm `Origin` matches." + +### Files to modify +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — register antiforgery and/or origin-check middleware. +- New middleware: `Idmt.Plugin/Middleware/OriginValidationMiddleware.cs` if path (2) chosen. +- Docs: XML docs + CLAUDE.md. + +### Verification +- Integration test: state-changing POST with cookie + `Origin: https://evil.com` → 403. +- Integration test: same call with `Origin` matching configured `ClientUrl` → 200. +- Integration test: bearer-authed state change unaffected. + +--- + +## Lows + +Ship as single hygiene PR at end of Phase 4. + +### L2 — No request-body size/length caps +All request records — FluentValidation cover format, not length. +**Fix**: add `.MaximumLength(256)` (or per-field appropriate limit) on every string input. Document Kestrel body-size limit recommendation. +**Files**: every file under `Idmt.Plugin/Validation/*`. + +### L3 — `ConfirmEmail` GET state-change on link-preview fetch +`Idmt.Plugin/Features/Auth/ConfirmEmail.cs:104-137`. Email security scanners auto-fetch links, consume tokens. +**Fix**: keep `EmailConfirmationMode.ClientForm` as default (already is). Document `ServerConfirm` risk prominent in XML docs and CLAUDE.md. + +### L4 — Revoked-token cleanup 1-hour startup delay +`Idmt.Plugin/Services/TokenRevocationCleanupService.cs:14-20`. `await Task.Delay(_interval)` before first pass. +**Fix**: run one cleanup pass immediately on `ExecuteAsync`, then enter loop. + +### L5 — `<` vs `<=` in revocation check +`Idmt.Plugin/Services/TokenRevocationService.cs:73`. Token issued at exact millisecond of revocation not revoked. Design-documented; keep as `<`. Add code comment explaining intentional exclusive comparison. + +### L6 — `ApiPrefix` not validated on `CreateTenant` `Location` response +`Idmt.Plugin/Features/Admin/CreateTenant.cs:155`. `Location` header use unvalidated `ApiPrefix`. +**Fix**: validate `ApiPrefix` as relative path at options load time. +**Files**: `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs`. + +### L7 — Customizer delegates can regress defaults +`Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs:404, 440`. `customizeAuthentication` / `customizeAuthorization` run *after* defaults and can replace policies with permissive ones. +**Fix**: document customizers as additive-only; consider adding separate `addAuthentication` / `addAuthorization` hooks that strictly additive. Start with docs; move to structural change only if abuse observed. + +### L9 — Health endpoint expose exception stack trace +`Idmt.Plugin/Features/Health/BasicHealthCheck.cs:34-39`. `HealthCheckResult.Unhealthy(..., ex, ...)` leak stack trace. Gated by `RequireSysUser` so limit to admins, but should scrub in production. +**Fix**: check hosting environment; in `Production` omit exception or pass sanitized summary. + +### L10 — CLAUDE.md mismatch +Should already update in Phase 1 as part of doc alignment. Re-verify in this phase. + +--- + +## Phase 4 implementation order + +1. **H5 + M1 + M3 + M4** — small-touch correctness fixes. One PR. +2. **M5 + M6** — user-management guard pass + endpoint-level auth backfill. One PR. +3. **M7 + M8** — async email dispatch + consistent PII masking. One PR. +4. **N7** — audit decoupling. Own PR because of scope + migration risk. +5. **N9** — antiforgery / origin validation. Own PR because of consumer impact. +6. **Lows** — batched hygiene PR at end. + +--- + +## Files to modify (summary) + +- `Idmt.Plugin/Features/Manage/UpdateUserInfo.cs` — H5, M4. +- `Idmt.Plugin/Features/Manage/GetUserInfo.cs` — M4. +- `Idmt.Plugin/Features/Manage/UpdateUser.cs` — M3, M5. +- `Idmt.Plugin/Features/Manage/UnregisterUser.cs` — M5. +- `Idmt.Plugin/Features/Auth/Login.cs` — M1. +- `Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs` — M7. +- `Idmt.Plugin/Features/Auth/ConfirmEmail.cs` — L3 doc. +- `Idmt.Plugin/Features/Admin/*` — M6 finalization. +- `Idmt.Plugin/Services/TokenRevocationCleanupService.cs` — L4. +- `Idmt.Plugin/Services/TokenRevocationService.cs` — L5 comment. +- `Idmt.Plugin/Services/TenantAccessService.cs` — M5 peer-rank. +- `Idmt.Plugin/Services/PiiMasker.cs` — M8 (if needed). +- `Idmt.Plugin/Persistence/IdmtDbContext.cs` — N7. +- `Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs` — N9 registration. +- New: `Idmt.Plugin/Middleware/OriginValidationMiddleware.cs` — N9. +- `Idmt.Plugin/Validation/*` — L2. +- `Idmt.Plugin/Configuration/IdmtOptionsValidator.cs` — L6. +- `Idmt.Plugin/Features/Health/BasicHealthCheck.cs` — L9. +- `Idmt.Plugin/Errors/IdmtErrors.cs` — new error codes (M5). +- `CLAUDE.md` — L10 verification. + +--- + +## Verification (phase-wide) + +- Each finding unit / integration tests listed above pass. +- Regression: full test suite continue pass. +- `dotnet test Idmt.slnx`, `dotnet format Idmt.slnx --verify-no-changes`, and warnings-as-errors build all pass. + +--- + +## Phase 4 done-criteria + +- `UpdateUserInfo` no longer present false transaction guarantee. +- Login timing equalized across null/inactive/known branches. +- User deactivation + tenant-access revocation invalidate both cookie and bearer sessions immediately. +- Handler lookups use canonical `Id` and validate security stamp post-lookup. +- Self-target and peer-rank destruction guarded by explicit opt-in. +- All admin endpoints have endpoint-level authorization metadata. +- `ResendConfirmationEmail` response time uniform regardless of user state. +- Identity error descriptions no longer logged verbatim; PII masking consistent. +- Audit writes decoupled from business-data transaction; security-critical table audits fail-closed. +- Antiforgery / origin validation layered on top of `SameSite=Strict` for cookie flows. +- Lows cleaned up (body-size caps, startup cleanup pass, scrubbed health-check, documented customizer contract, validated `ApiPrefix`, CLAUDE.md accurate). +- Full test suite + format + warnings-as-errors pass. + +End state: plugin security posture match consolidated audit recommendations. Follow-up work (beyond this plan): dedicated `Idmt.SecurityTests` project, session inventory / per-device revocation granularity, per-tenant DataProtection isolation, MFA / step-up for sys operations. \ No newline at end of file diff --git a/adr/0001-canonical-identity-and-tenant-access.md b/adr/0001-canonical-identity-and-tenant-access.md new file mode 100644 index 0000000..38fb02f --- /dev/null +++ b/adr/0001-canonical-identity-and-tenant-access.md @@ -0,0 +1,330 @@ +# ADR 0001 — Canonical Identity & Tenant Access + +- **Status:** Proposed — superseded in part by ADR-0002 +- **Date:** 2026-04-28 +- **Deciders:** @idotta +- **Affects:** `idmt-plugin`, `preditor-cloud/src/Persistence`, `preditor-cloud/src/API` +- **Supersedes:** Per-tenant `IdmtUser` shadow-row model +- **Superseded by:** ADR-0002 §2.3–2.4 (`ServerSession`, `/sys-switch`, step-up) — replaced by OpenIddict reference tokens and RFC 8693 token exchange. The canonical identity, `TenantAccess`, and `SysRole` model below remains in force. + +## 1. Context + +PreditorCloud is a multi-tenant IoT asset platform built on .NET 10 + Finbuckle.MultiTenant with route-based tenancy (`/api/v1/{__tenant__}/...`). Per-row tenant isolation is the agreed strategy for application entities (Asset, Unit, AssetToken, MeasurementSetup) and is **not** under review here. + +Identity, however, is currently also per-row. `IdmtUser` carries a `TenantId` column, and `GrantTenantAccess.cs:117-133` creates a **shadow row** in the target tenant when granting cross-tenant access — copying `PasswordHash` and `LockoutEnd` while generating a fresh `Id` and `SecurityStamp`. + +This model breaks identity coherence in multi-tenant scenarios: + +| Operation | Effect today | +|-----------|-------------| +| Password rotation | Updates only the current-tenant row. Other shadow rows retain old hash. | +| `UpdateSecurityStampAsync` | Affects only the current-tenant row. | +| `TokenRevocationService.RevokeUserTokensAsync(userId, tenantId)` | Keys on the row-specific `userId`; shadow rows have different ids, so revocations never propagate. | +| Lockout state | Locked in tenant A, still active in tenant B. | +| Email change | Updates only one row → drift. | + +The product intent is that **system users (SysAdmin / SysSupport) hop into any tenant**, and that regular users may belong to multiple tenants. Both use cases are served better by a canonical identity model than by shadow rows. + +## 2. Decision + +Adopt a **canonical-identity** model for all Identity tables, with tenant association expressed exclusively via a new `TenantAccess` aggregate. System-level capabilities are expressed via a `SysRole` aggregate orthogonal to tenants. Authorization, session storage, and account-state operations are designed for blast-radius containment commensurate with the elevated coupling that canonicalization introduces. + +### 2.1 Schema changes + +Drop `TenantId` from **all** Identity tables: `IdmtUser`, `IdmtRole`, `AspNetUserClaims`, `AspNetUserLogins`, `AspNetUserTokens`, `AspNetRoleClaims`. Retire `AspNetUserRoles` (its job moves to `TenantAccessRole`). + +```text +IdmtUser + Id uuid PK + Email text UNIQUE (globally unique) + NormalizedEmail text UNIQUE + PasswordHash text + SecurityStamp text + ConcurrencyStamp text + LockoutEnd timestamptz NULL + AccessFailedCount int (see §2.5) + EmailConfirmed bool + TwoFactorEnabled bool + PendingEmail text NULL (see §2.6) + PendingEmailExpiresAt timestamptz NULL + -- no TenantId + +TenantAccess + UserId uuid FK → IdmtUser.Id + TenantId text FK → TenantInfo.Identifier + GrantedAt timestamptz + GrantedBy uuid FK → IdmtUser.Id + PRIMARY KEY (UserId, TenantId) + +TenantAccessRole (junction — replaces AspNetUserRoles) + UserId uuid + TenantId text + RoleName text FK → IdmtRole.Name + PRIMARY KEY (UserId, TenantId, RoleName) + FOREIGN KEY (UserId, TenantId) REFERENCES TenantAccess + +SysRoleAssignment (table, not column — extensibility) + UserId uuid FK → IdmtUser.Id + SysRoleName text ("SysAdmin" | "SysSupport" | future) + GrantedAt timestamptz + GrantedBy uuid FK → IdmtUser.Id + ExpiresAt timestamptz NULL (optional time-bounded grants) + PRIMARY KEY (UserId, SysRoleName) + +UserLockout (per-(user, tenant) — see §2.5) + UserId uuid + TenantId text NULL (NULL = global lockout from sys-level events) + AccessFailedCount int + LockoutEnd timestamptz NULL + PRIMARY KEY (UserId, COALESCE(TenantId, '__global__')) + +ServerSession (see §2.4) + SessionId uuid PK + UserId uuid FK → IdmtUser.Id + TenantId text (which tenant this session is bound to) + IsSysSession bool (true for sessions minted via /sys-switch) + CreatedAt timestamptz + ExpiresAt timestamptz (≤15 min for IsSysSession=true) + RevokedAt timestamptz NULL + ReasonClaim text NULL (required for IsSysSession=true) + IpAddress inet + UserAgent text + +EmailChangeAudit + Id uuid PK + UserId uuid + OldEmail text + NewEmail text + Action text ("requested" | "confirmed" | "cancelled" | "expired") + At timestamptz + IpAddress inet +``` + +### 2.2 Authorization model + +`SysRoleAssignment` grants the **capability** to assume a per-tenant scoped session via the `/sys-switch` endpoint. It does **not** grant ambient access. Every request authorizes against `TenantAccess` for the route tenant, with one of: + +- An explicit `TenantAccess(UserId, RouteTenant)` row, **or** +- An active `ServerSession` row where `IsSysSession = true AND TenantId = RouteTenant`. + +```csharp +// pseudocode — applied by an authorization handler, not ad hoc +public async Task CanAccessTenantAsync(Guid userId, string routeTenant, Guid sessionId) +{ + var session = await sessions.FindAsync(sessionId); + if (session is null || session.RevokedAt is not null || session.ExpiresAt < utcNow) + return false; + + if (session.TenantId != routeTenant) + return false; // cookie is scoped — no cross-tenant reuse + + if (session.IsSysSession) + { + await audit.LogAsync(userId, routeTenant, "SysSessionAccess", session.ReasonClaim); + return true; + } + + return await tenantAccess.ExistsAsync(userId, routeTenant); +} +``` + +Bypass **on every request** is rejected. Sys access is per-session, time-bounded, and audit-logged with a caller-supplied `Reason`. + +### 2.3 Sys-switch flow + +A user holding any active `SysRoleAssignment` may request elevated access to a tenant: + +``` +POST /api/v1/system-tenant/sys-switch + body: { targetTenant: "acme", reason: "support ticket #1234" } + requires: valid system-tenant cookie + step-up auth (re-prompt password or WebAuthn) +``` + +The endpoint: + +1. Verifies the caller has a non-expired `SysRoleAssignment`. +2. Requires a step-up auth challenge completed within the last 5 minutes (tracked via `ServerSession.LastStepUpAt`). +3. Mints a new `ServerSession` row with `IsSysSession = true`, `TenantId = targetTenant`, `ExpiresAt = now + 15 min`, `ReasonClaim = req.reason`. +4. Returns `Set-Cookie: .Idmt.Application.{targetTenant}=...` (opaque session id). +5. Writes a tamper-evident audit event to an external sink (Serilog → file + forwarded; replace with append-only store before GA). + +Concurrent sys-sessions are allowed (e.g., support engineer holds active sessions in three tenants simultaneously), but each is independently revocable. + +### 2.4 Cookie and session model + +Cookies remain **per-tenant** (`.Idmt.Application.{tenant}`, `SameSite=Strict`, `HttpOnly`, `Secure` in non-dev). The cookie payload is an **opaque session id**, not a self-contained ticket. Authorization, role membership, and SysRole status are read from the `ServerSession` + `TenantAccess` + `SysRoleAssignment` tables on every authenticated request. + +Implementation: cache lookups for ~30s in `IMemoryCache` keyed by `SessionId` to bound the per-request DB cost; cache invalidated on revocation events. + +This replaces the stateless ASP.NET Identity cookie model. Justification: the canonical-identity model concentrates blast radius; opaque server-side sessions enable instant revocation and per-(user, tenant) session inspection. Mature identity stacks (Auth0, Okta, Atlassian) follow this pattern for the same reason. + +`UpdateSecurityStampAsync` semantics under this model: bumping `SecurityStamp` invalidates **all** `ServerSession` rows for the user. Used for password change, email change, and confirmed account compromise. Not used for tenant-access revocation (see §2.7). + +### 2.5 Lockout — per-(user, tenant) + +Account lockout becomes a per-(user, tenant) primitive to prevent cross-tenant denial of service. An attacker brute-forcing alice@corp through tenant A's login locks her out only of tenant A. Five failed attempts in five minutes triggers a per-tenant lockout. + +A separate **global** lockout (with `TenantId = NULL`) is reserved for sys-level events: confirmed compromise, admin-initiated lock, or anomaly detection signals. Global lockout invalidates all `ServerSession` rows. + +Rate limiting at the edge (per-IP, per-device fingerprint) applies in addition to per-account counters and is the first line of defense against credential-stuffing. + +### 2.6 Email change — `PendingEmail` column + Identity token + +Use ASP.NET Identity's `GenerateChangeEmailTokenAsync` / `ChangeEmailAsync` for cryptographic verification. Add `PendingEmail` and `PendingEmailExpiresAt` columns on `IdmtUser` for state, reservation, and UX. + +Flow: + +``` +1. POST /api/v1/{tenant}/account/email + - reject if `PendingEmail` or `Email` for newEmail already exists + - token = GenerateChangeEmailTokenAsync(user, newEmail) + - user.PendingEmail = newEmail; PendingEmailExpiresAt = now + 24h + - send verification mail to newEmail + - audit: requested + +2. POST /api/v1/{tenant}/account/email/confirm { token } + - if PendingEmailExpiresAt < now → 410 Gone, clear PendingEmail + - ChangeEmailAsync(user, PendingEmail, token) — Identity bumps SecurityStamp + - PendingEmail = NULL; PendingEmailExpiresAt = NULL + - audit: confirmed + +3. POST /api/v1/{tenant}/account/email/cancel + - PendingEmail = NULL; PendingEmailExpiresAt = NULL + - audit: cancelled +``` + +Login during pending state continues to use the old confirmed `Email`. Background sweeper (or read-time check) clears expired `PendingEmail` to release the reservation. + +Uniqueness invariant: no email may appear in `Email` OR `PendingEmail` of any row twice. Enforced via partial unique index where database supports it (Postgres) or app-layer check + retry (SQLite). + +### 2.7 Tenant-access revocation + +`RevokeTenantAccess(userId, tenantId)`: + +1. Delete `TenantAccess` and `TenantAccessRole` rows for the pair. +2. Mark `RevokedAt = now` on every `ServerSession` row matching `(UserId, TenantId)` where `IsSysSession = false`. +3. Do **not** bump `SecurityStamp` (would kick the user from unrelated tenants). + +Sys-session revocation: clearing a `SysRoleAssignment` marks `RevokedAt` on every `ServerSession` row where `IsSysSession = true AND UserId = X`. Other tenant sessions for that user (where they are a regular member) survive. + +### 2.8 Discover-tenants — leak prevention + +The unauthenticated `/discover-tenants?email=X` endpoint **must not** distinguish sysusers from regular users. It returns the union of: + +- `TenantInfo` rows joined to `TenantAccess` rows for the user, **only**. + +Sysusers see their cross-tenant inventory only via the **authenticated** `GET /api/v1/system-tenant/tenants` endpoint, accessible after they have completed login + step-up auth. This prevents email-enumeration attacks from exfiltrating the customer list. + +Returned shape and response time must be identical regardless of whether the email exists or has SysRole. + +### 2.9 Endpoint surface — split + +Tenant-membership management (any TenantAdmin within the tenant): + +- `POST /api/v1/{tenant}/grants` — invite / grant. **Reject (409) if target user has any `SysRoleAssignment`.** +- `PATCH /api/v1/{tenant}/grants/{userId}` — change roles. +- `DELETE /api/v1/{tenant}/grants/{userId}` — revoke (per §2.7). + +Sys-user management (system-tenant, requires SysAdmin): + +- `POST /api/v1/system-tenant/sys-users` — create user with SysRole. +- `POST /api/v1/system-tenant/sys-users/{id}/roles` — add SysRole. +- `DELETE /api/v1/system-tenant/sys-users/{id}/roles/{roleName}` — remove SysRole (revokes sys-sessions per §2.7). +- `POST /api/v1/system-tenant/sys-switch` — mint scoped session (§2.3). + +A user must not simultaneously hold `SysRoleAssignment` rows and `TenantAccess` rows. Enforced by API-layer guard on grant/sys-grant operations. + +## 3. Migration plan + +This is a destructive schema change. Performed pre-production while data volume is low. + +### 3.1 Per-column fold rules (when consolidating shadow rows) + +Group existing `IdmtUser` rows by `NormalizedEmail`. For each group, produce one canonical row: + +| Column | Fold rule | +|--------|-----------| +| `Id` | New uuid generated; map old (TenantId, OldId) → NewId. | +| `PasswordHash` | Most recently changed (use `SecurityStamp` rotation timestamp if available, else fail and force reset). | +| `SecurityStamp` | New value generated; all sessions invalidated post-migration. | +| `LockoutEnd`, `AccessFailedCount` | **Most restrictive** wins (latest LockoutEnd, highest count). Active locks honored. | +| `TwoFactorEnabled` | **True if any row is true.** Never silently downgrade. | +| `EmailConfirmed` | **False if any row is false.** Force re-confirm on first login if any divergence. | +| `PhoneNumber` | Latest non-null. | + +A **dry-run migration** runs first and emits a divergence report listing every email with conflicting columns. Sign-off required before destructive migration runs. + +For PreditorCloud's pre-prod state: force a password reset for all users at cutover. Eliminates the `PasswordHash` ambiguity entirely. + +### 3.2 Step sequence + +1. Add new tables (`TenantAccess`, `TenantAccessRole`, `SysRoleAssignment`, `UserLockout`, `ServerSession`, `EmailChangeAudit`). +2. Run dry-run consolidation; review divergence report. +3. Enter maintenance window. Drop active sessions. +4. Consolidate `IdmtUser` rows; populate `TenantAccess`, `TenantAccessRole`, `SysRoleAssignment` from the old shadow-row + role tables. +5. Drop `TenantId` from Identity tables; drop `AspNetUserRoles`. +6. Force password reset email to all users. +7. Deploy new authorization stack. +8. Smoke-test §4. + +## 4. Test strategy + +CI must enforce the new isolation guarantees. Without these, the loss of physical-row defense-in-depth (one row per tenant) is unmitigated. + +- **Cross-tenant 403 assertions.** For every Identity-adjacent endpoint, an integration test that authenticates as a user with access to tenant A and asserts 403/404 against tenant B. Generated, not hand-rolled per endpoint. +- **Route-mutation fuzzer.** A CI step that takes the OpenAPI surface, picks an authenticated session for tenant A, and mutates the `__tenant__` segment to every other known tenant, asserting 403/404. Catches accidental Finbuckle-filter bypasses. +- **Sys-session expiry test.** Mint a sys-session, wait past `ExpiresAt`, assert 401 on next request even if the cookie is still in the browser. +- **Sys-revocation propagation test.** Mint sys-sessions in tenants A, B, C; revoke `SysRoleAssignment`; assert all three sessions 401 within the cache TTL. +- **Email-reservation race test.** Two concurrent change-email requests to the same `newEmail`; assert exactly one wins and the other gets 409 before sending verification mail. +- **Lockout scope test.** Trigger per-tenant lockout in tenant A; assert login still works in tenant B for the same user. + +## 5. Consequences + +### 5.1 Positive + +- Password rotation, security-stamp bumps, lockouts, and email changes propagate correctly across tenants by construction (single row). +- `TokenRevocationService` becomes coherent: revoke by canonical `UserId` and all sessions die. +- Shadow-row copy logic in `GrantTenantAccess.cs` is deleted; the endpoint becomes a single `INSERT INTO TenantAccess`. +- Discover-tenants becomes a one-line query against `TenantAccess`. +- Sys-user management has a dedicated endpoint surface, separated from tenant membership. +- Per-(user, tenant) lockout and per-tenant cookies preserve isolation where it matters. +- Server-side sessions enable instant revocation, audit forensics, and concurrent-session inspection. + +### 5.2 Negative / risk + +- **Credential blast radius increases.** A stolen `PasswordHash` grants access to all of the user's tenants. Mitigations: mandate WebAuthn or TOTP for any user with `TenantAccess` in more than one tenant; mandate WebAuthn for `SysRoleAssignment` holders; per-tenant session binding so a stolen *session* cannot port; anomaly detection on first-tenant-access-from-unfamiliar-device. +- **Defense-in-depth from physical row separation is lost.** Compensated by the §4 test strategy and consideration of database-level RLS on Identity tables (deferred — Postgres-only, evaluate when the platform commits to a single DB engine). +- **Server-side sessions add a per-request lookup cost.** Mitigated by 30s `IMemoryCache`. At expected scale (≤10⁴ concurrent sessions) this is below noise. +- **Migration is destructive.** Mitigated by pre-prod state, dry-run, forced password reset. +- **Operational complexity.** Step-up auth, sys-session TTL, and audit-log shipping add infra. Acceptable cost for a platform that ships SysAdmin capability to vendor staff. + +### 5.3 Explicitly out of scope + +- Database-level row security policies on Identity tables (revisit if Postgres becomes the single supported engine). +- Granular per-(sysuser, tenant) deny lists. If granular denial is needed, demote the user from SysRole to per-tenant `TenantAccess` membership. +- Same-human-multiple-identities. One email = one canonical user. Users needing distinct identities use distinct emails. + +## 6. Alternatives considered + +1. **Keep shadow-row model, add a "sync" service** that propagates password / stamp / lockout changes across rows. Rejected: synchronization across rows has its own race conditions and recovery semantics; the bug class is intrinsic to the model. +2. **Canonical user, but require explicit `TenantAccess` row even for sysusers** (no bypass). Rejected: contradicts "SysUsers hop into any tenant" product intent; auto-grant logic on every new-tenant creation is a recurring drift surface; sys-scoped behavior should be expressed explicitly via `SysRoleAssignment`, not duplicated as TenantAccess rows. +3. **Canonical user, ambient SysRole bypass on every request.** Rejected: 1990s root-account anti-pattern; one stolen sysuser cookie compromises the entire platform with no time bound; matches the shape of the Okta October 2023 incident. +4. **Stateless ASP.NET Identity cookies** (no `ServerSession` table). Rejected: sysrole revocation gap (a revoked sysuser's existing cookies remain valid until expiry) is unacceptable for an admin platform. +5. **`TenantAccess.Roles[]` as array column** instead of `TenantAccessRole` junction. Rejected: breaks referential integrity, complicates role rename, and re-introduces the "string column with structure" smell that AspNetUserRoles existed to solve. +6. **`SysRole` as nullable column** on `IdmtUser` instead of `SysRoleAssignment` table. Rejected: a future "SysBilling" or "SysAuditor" role triggers a schema migration; expressing as a join table keeps the catalog open. +7. **Pending-email change in a separate `PendingEmailChange` table**. Rejected for current scope: one-pending-at-a-time semantics fit a column. Revisit if multi-channel verification (verify both old and new) becomes a requirement. + +## 7. Open questions + +- Audit log destination: Serilog file + forwarder is sufficient short-term. Long-term sink (S3 with object-lock, dedicated SIEM, immutable PG table) is unresolved. +- WebAuthn enforcement timeline. Recommended at GA for any user with `TenantAccess` in >1 tenant and unconditionally for `SysRoleAssignment` holders. Implementation is non-trivial; track separately. +- Anomaly detection for first-tenant-access-from-unfamiliar-device. Out of scope for this ADR; raise as a follow-on once observability is in place. + +## 8. References + +- `idmt-plugin/src/.../GrantTenantAccess.cs:117-133` — current shadow-row implementation. +- `preditor-cloud/src/Persistence/Configuration/AssetTokenConfiguration.cs` — example per-row tenant config pattern (retained for app entities). +- CLAUDE.md §"Auth Flow" — current login flow. +- Okta October 2023 security incident — root-account model failure mode (cited as anti-pattern, not as direct precedent). +- ASP.NET Core Identity — `UserManager.GenerateChangeEmailTokenAsync` / `ChangeEmailAsync`. +- Finbuckle.MultiTenant — `IsMultiTenant()` is **not** applied to Identity tables under this design. diff --git a/adr/0002-idmt-v2-openiddict-authorization-layer.md b/adr/0002-idmt-v2-openiddict-authorization-layer.md new file mode 100644 index 0000000..60e2520 --- /dev/null +++ b/adr/0002-idmt-v2-openiddict-authorization-layer.md @@ -0,0 +1,712 @@ +# ADR 0002 — IDMT v2: OpenIddict-based multi-tenant authorization layer + +- **Status:** Accepted — prototype gate passed (see [§7](#7-prototype-gate-and-open-questions)) +- **Date:** June 4, 2026 (accepted June 5, 2026) +- **Deciders:** @idotta +- **Affects:** `idmt-plugin` (v2 greenfield rewrite), downstream .NET products that consume it +- **Supersedes:** ADR-0001 §2.3–2.4 (`ServerSession`, `/sys-switch`, step-up) — in part + +## 1. Context + +This ADR records the target architecture for a greenfield v2 rewrite of IDMT. +It commits to a single design so implementation proceeds against one source of +truth. The design itself was produced by three parallel architect sketches and +a scored evaluation; this document is the decision, and those artifacts are the +research behind it (see [References](#8-references)). + +IDMT v1 hand-rolls its own bearer-token machinery on top of ASP.NET Core +Identity and Finbuckle.MultiTenant. A multi-agent security audit +(`SECURITY_AUDIT.md`) found that the remaining work is not "harden our auth" but +"build an identity provider." The open backlog — access-token revocation +enforcement (C1), refresh-token rotation (N5), opaque server-side sessions, +a sys-switch flow, step-up authentication, and multi-factor authentication — is +all commodity identity-provider machinery. ADR-0001 proposed to build a large +part of it by hand (a `ServerSession` table, a `/sys-switch` endpoint, step-up +tracking). + +The v2 insight is that you must stop competing with mature identity engines on +commodity machinery and instead own only the part that is genuinely yours: the +multi-tenant authorization model and the endpoint scaffolding. **OpenIddict** +provides the protocol engine; IDMT provides the policy. + +OpenIddict closes the audit backlog structurally rather than line by line: + +| Audit / ADR-0001 item | How v2 closes it | +|---|---| +| C1 — access tokens never checked for revocation | Reference (opaque) access tokens, validated server-side on every request | +| N5 — no refresh-token rotation | OpenIddict refresh-token rotation with reuse detection | +| M2 — `IssuedUtc` drift | Handled by the engine's token store | +| `TokenRevocationService` / `RevokedToken` | The OpenIddict token store is authoritative | +| ADR-0001 `ServerSession` + 30s cache + opaque cookie | Reference tokens — token data lives server-side, the wire value is a handle | +| ADR-0001 `/sys-switch` | Server-side support-token mint with the RFC 8693 `act` claim | + +v2 retains ASP.NET Core Identity as the user store, Finbuckle for tenant +resolution, and the canonical identity model from ADR-0001 as design choices. It +does not build the bearer-token and session machinery OpenIddict now owns. + +## 2. Decision + +This section records the committed architecture. Each subsection is a decision, +not an option. + +### 2.1 Thesis: own the policy, rent the protocol + +IDMT v2 is a thin, opinionated multi-tenant authorization layer wrapped around +OpenIddict. The division of responsibility is fixed. + +OpenIddict owns every commodity OAuth 2.0 and OpenID Connect concern: the +authorize, token, introspection, revocation, and userinfo endpoints; refresh +rotation; and reference tokens. ASP.NET Core Identity remains +the user and credential store. Finbuckle.MultiTenant remains the tenant +resolver. IDMT contributes exactly three things of its own: the canonical +identity and `TenantAccess` and `SysRole` authorization model projected into +tokens, the opinionated wiring that composes these engines correctly for +multi-tenancy, and the endpoint scaffolding that hands consumers pre-authorized +route groups for both the tenant side and the system-admin side. + +### 2.2 Module boundaries: three packages + +v2 ships as three NuGet packages. The boundary that matters is the one that +keeps infrastructure types out of the domain, and you enforce it with a test, +not a convention. + +- `Idmt.Core` — the domain. Canonical `IdmtUser`, `IdmtRole`, `TenantAccess`, + `SysRole`, the authorization policies, the support-capability rule, and the + repository and service ports. This package references no infrastructure: no + OpenIddict, no Finbuckle, no Entity Framework Core, no ASP.NET Core. +- `Idmt.AspNetCore` — the composition root and the only package most consumers + add. It pulls `Idmt.Core` and hosts the OpenIddict, Finbuckle, Entity + Framework Core, endpoint, and email integrations in dedicated folders + (`Server/`, `MultiTenancy/`, `Persistence/`, `Endpoints/`). Vendor types live + here, isolated by folder. +- `Idmt.Mfa` — opt-in multi-factor support (TOTP now, WebAuthn through + `fido2-net-lib` later). It is a separate package so the WebAuthn dependency + stays off the main package for consumers who do not need it. + +An `Idmt.Architecture.Tests` project enforces the dependency rule as a fitness +function: `Idmt.Core` must not reference any infrastructure assembly. This makes +the firewall a compile-and-test guarantee rather than a code-review habit. v1 +conflated "feature folder" with "layer," which is how `GrantTenantAccess.cs` +ended up performing shadow-row surgery; the architecture test prevents the +recurrence regardless of how few packages you ship. + +The architecture test recovers the domain-isolation benefit of a finer split, but +not all of it. With OpenIddict, Finbuckle, and Entity Framework Core all hosted in +`Idmt.AspNetCore`, a major-version bump in any one of them touches the same +assembly. We consciously accept that vendor-version blast radius as the cost of +shipping three packages instead of five. + +### 2.3 OpenIddict as the protocol engine + +OpenIddict issues and validates all tokens. Two engine choices are locked +because reversing them would reopen the gaps this ADR exists to close. + +Access tokens are **reference (opaque) tokens**. The wire value is a handle, and +the token data lives in the server-side store. Per-request revocation checking is +not OpenIddict's default; it requires `EnableTokenEntryValidation()`, which is +mandatory whenever reference tokens are used and which enforces revocation only +when the API uses the co-hosted local validation handler (`UseLocalServer()`), +not remote introspection. With that call in place, validation reads the token +entry, and a revocation is a single row update that takes effect on the next +request for any instance whose view of the token store is not stale. When the +local handler caches token-entry lookups and the deployment runs more than one +instance, that staleness window is bounded by the cache lifetime, which is the +scale-out concern that [§5.2](#52-risk-and-mitigation) treats as a near-term +backplane requirement, not by any property of the wire token. We lock both +`EnableTokenEntryValidation()` and the local +validation handler in [§2.9](#29-the-opinionated-and-customizable-seam); without +them, instant revocation silently regresses to expiry-only, which is the exact v1 +gap (audit finding C1) this design closes. Self-contained JWT access tokens are +not offered as an option, because that choice would reintroduce the same latency. +ID tokens for OpenID Connect clients remain signed JWTs, which is +protocol-correct and not a revocation concern. + +Refresh tokens rotate on every use, with reuse detection. The protocol endpoints +follow OpenIddict conventions (`/connect/token`, `/connect/authorize`, +`/connect/introspect`, `/connect/revoke`, `/connect/userinfo`). + +Per-request revocation through the local validation handler assumes the resource +API is co-hosted with the OpenIddict server in one deployable. v2 commits to that +topology: the consuming product hosts both, so `UseLocalServer()` is available and +revocation is enforced against the shared token store. An out-of-process resource +server cannot use the local handler and falls back to remote introspection, which +does not enforce per-request revocation and so reopens the C1 gap. Distributed +resource servers are out of scope for v2; the split-deployment story, including +whether introspection without response caching is acceptable, is an open question +(see [§7.1](#71-open-questions)). + +### 2.4 Authentication model: bearer-only APIs + +API traffic authenticates with bearer reference tokens only. This removes v1's +hybrid cookie-or-bearer model and the bug class that came with it. + +v1 ran a `CookieOrBearer` policy scheme that selected cookie or bearer +authentication per request, and a `ValidateBearerTokenTenantMiddleware` that +re-checked the tenant. The two paths diverged in subtle ways. In v2, every +**resource** request carries a reference token, and v2 does not build a +`CookieOrBearer` resource-layer scheme at all. + +This does not remove cookies entirely; it confines them to two roles, neither of +which is a resource-layer credential. First, the authorization-code flow in +[§2.5](#25-login-grant-authorization-code-with-pkce) requires an interactive, +cookie-backed session at `/connect/authorize`, where the user signs in before a +code is issued. Second, a first-party single-page application authenticates +through a backend-for-frontend session cookie +([§2.5.1](#251-browser-clients-use-a-backend-for-frontend-session)) that the host +resolves to a server-side reference token before any resource logic runs. Both +cookies must stay tenant-aware, so they use per-tenant naming, the same approach +v1 took, and §4 tests that neither can be replayed across tenants. The cookie +complexity moves from the resource layer to the authorize endpoint and the +backend-for-frontend layer rather than disappearing. The resource API itself +accepts only a reference token. The tenant re-check that v1 did in +`ValidateBearerTokenTenantMiddleware` is replaced by an IDMT-owned validation +handler described in [§2.6](#26-multi-tenancy-integration), not by deleting the +check. + +### 2.5 Login grant: authorization code with PKCE + +Interactive login uses the authorization-code flow with PKCE. This is the +OAuth 2.1-aligned choice and it does not depend on a grant type that the spec is +removing. + +The resource-owner password grant is not surfaced. OAuth 2.1 removes it, and +building login on it would create a known dead end. Trusted first-party and +machine clients authenticate through the client-credentials flow or a documented +code exchange rather than by posting user credentials to the token endpoint. The +exact shape of first-party machine authentication is an open question (see +[§7](#7-prototype-gate-and-open-questions)), but the decision to avoid the +password grant is fixed. + +### 2.5.1 Browser clients use a backend-for-frontend session + +A first-party single-page application authenticates through a +backend-for-frontend session, not by holding a token in the browser. This is the +one sanctioned way a cookie reaches a request that ends at a resource endpoint, +and it is deliberately not the v1 hybrid. + +Storing an access or refresh token in JavaScript exposes it to exfiltration +through any cross-site-scripting flaw, so v2 never hands a token to the browser. +Instead, the single-page app runs the authorization-code flow with PKCE against +the co-hosted host, and the host keeps the resulting reference token server-side. +The browser holds only an `httpOnly`, `Secure`, `SameSite` session cookie. The +host maps that cookie to the server-side reference token on each request and +processes the request through the **same** reference-token path every other client +uses: the same `TenantAccess` gate, the same audience validation handler, and the +same revocation. The cookie is one more handle to the same server-side token +entry, exactly as the reference token is a handle to server-side token data. + +This is why the backend-for-frontend session is not v1's cookie-or-bearer hybrid. +The v1 bug class came from the resource layer validating a cookie path +*independently* of the bearer path, so the two diverged. Here the resource layer +still validates exactly one thing — the reference token — and the cookie is +resolved to that token before any resource logic runs. The resource API never +accepts a cookie as a credential of its own. + +Three properties are fixed. The session cookie reuses the per-tenant naming from +[§2.4](#24-authentication-model-bearer-only-apis), so a tenant-A session cannot +drive a tenant-B request. Because the browser now sends an ambient credential, +cross-site request forgery protection (a `SameSite` cookie plus an anti-forgery +token) is mandatory and is in the [§2.9](#29-the-opinionated-and-customizable-seam) +locked set. The cookie-to-token resolution is the only place a cookie touches a +resource request, and the [§7.0](#70-prototype-gate-precondition-to-ratification) +gate must prove it runs the same audience handler as a raw bearer request. + +The co-hosting commitment from [§2.3](#23-openiddict-as-the-protocol-engine) is +what makes this cheap: the backend that holds the session is the same process that +hosts the authorization server and the resource endpoints, so the +backend-for-frontend layer is not a new deployable. A consumer that does not ship +a browser client ignores this entirely; the session surface is opt-in. + +### 2.6 Multi-tenancy integration + +Finbuckle resolves the tenant from the request, and the token's audience binds +the token to that tenant. The token audience is the single source of truth at the +resource, but both stamping it and checking it are code IDMT owns; the engine +does neither dynamically on its own. + +At issuance, the tenant is resolved and stamped into the access token's `aud` +claim. The authorization-code flow resolves the tenant at `/connect/authorize`. +The refresh grant reaches `/connect/token` with no tenant route segment, so the +client supplies the tenant through the RFC 8707 `resource` parameter as +`urn:idmt:tenant:{identifier}`. (Support tokens carry no public grant; IDMT mints +them server-side and sets their `aud` directly — see +[§2.8](#28-system-support-through-a-server-side-token-mint).) We lock the `resource`-parameter +convention rather than route-based resolution (`/{tenant}/connect/token`) so the +OpenID Connect discovery document stays single-issuer and conformant. The cost is +that clients must send the `resource` parameter, which is a documented +requirement. + +For a refresh, the tenant is authoritative from the presented refresh token's +original `aud`, not from the `resource` parameter. If a client sends a `resource` +parameter on refresh, it must match the token's `aud`; a mismatch is rejected. +This precedence prevents a client from presenting a tenant-A refresh token with +`resource=urn:idmt:tenant:B` to mint a tenant-B access token. The §4 cross-grant +audience-isolation test asserts exactly this rejection. + +At the resource, OpenIddict's built-in audience validation compares `aud` only +against a **static** configured audience set, not against a per-request resolved +tenant. Per-request enforcement is therefore an **IDMT-owned validation handler** +that compares the token's `aud` to the Finbuckle-resolved tenant through +`IMultiTenantContextAccessor` and rejects a mismatch. This handler is the +successor to v1's `ValidateBearerTokenTenantMiddleware`, relocated into the +OpenIddict validation pipeline and the correct layer, not deleted. It is in the +[§2.9](#29-the-opinionated-and-customizable-seam) locked set, and the §4 +route-mutation fuzzer exercises the real handler. + +The OpenIddict Entity Framework Core stores (application, authorization, scope, +and token) live in a **separate `DbContext` that does not derive from Finbuckle's +`MultiTenantDbContext`**. This is mandatory, not a tuning choice. Finbuckle does +not only add read-side query filters; it also stamps `TenantId` onto tracked +multi-tenant entities on `SaveChanges` and treats a context's tenant as fixed for +its lifetime. The token endpoint issues tokens in a pipeline scope where the +ambient tenant is often unset, so routing OpenIddict's writes through a +multi-tenant context would throw or mis-stamp. A dedicated, tenant-agnostic +context for the OAuth tables avoids both the save-side stamping and the read-side +filtering. Proving this composition end to end is the first item of the +[§7](#7-prototype-gate-and-open-questions) prototype gate. + +### 2.7 Canonical identity, carried from ADR-0001 + +The identity model from ADR-0001 stays, and the access gate gets stronger. This +is the part of ADR-0001 that v2 keeps rather than supersedes. + +`IdmtUser` remains the global canonical identity, one row per human, with a +globally unique normalized email. `IdmtRole` remains per-tenant. `SysRole` +remains the global system-role flag. `TenantAccess` remains the user-to-tenant +edge with `IsActive` and optional `ExpiresAt`. The uniform `TenantAccess` gate +remains: no user, including a system administrator, gets a token for a tenant +without an active, unexpired `TenantAccess` row. In v2 the gate runs not only at +login but at token issuance across every grant type, and at every server-side +support-token mint ([§2.8](#28-system-support-through-a-server-side-token-mint)). + +Propagating credential changes to issued tokens is IDMT's responsibility, not an +automatic engine behavior. ASP.NET Core Identity's `SecurityStamp` rotation does +not revoke OpenIddict reference tokens on its own. IDMT registers a hook on the +credential-change paths (password change, email change, `UpdateSecurityStampAsync`, +deactivation, and compromise response). For a full credential change, the hook +drops every token the user holds in one call: +`IOpenIddictTokenManager.RevokeBySubjectAsync`. A token entry records no audience +to filter on — the audience lives only in the encrypted token payload — so +dropping a *single* tenant's tokens uses **authorization grouping** instead: +every tenant-scoped token a user holds is minted under one OpenIddict +authorization keyed to (user, tenant), and revoking that tenant calls +`RevokeByAuthorizationIdAsync`. The prototype proved both single calls against +real infrastructure, including a 100-token user, so cost does not scale with the +number of tokens held. The `SecurityStamp` remains the source-of-truth signal; +this hook is the enforcement, and it is in the +[§2.9](#29-the-opinionated-and-customizable-seam) locked set. + +### 2.8 System support through a server-side token mint + +A system user supports a tenant by having IDMT mint a tenant-scoped, +time-bounded, audited support token on their behalf. This replaces v1's +shadow-row approach and ADR-0001's `/sys-switch` design, and it introduces no +account duplication. + +A support token is an ordinary tenant-audienced reference token with a `support` +scope and an actor claim that names the system user. The actor claim is the +standard RFC 8693 `act` claim; `support_of` is IDMT's surfaced alias for it, so +implementers project the standard claim rather than inventing a second one. +Because it is a normal reference token, it shares one revocation, expiry, and +audience code path with every other token; there is no second session table and +no `IsSysSession` branch threaded through authorization. + +IDMT mints the support token server-side, through +`IOpenIddictTokenManager.CreateAsync` inside a transaction IDMT owns, rather than +exposing RFC 8693 as a public grant on `/connect/token`. This is a deliberate +constraint, and the prototype proved it is the only shape that satisfies the +audit-atomicity property below. OpenIddict's grant pipeline creates the token +through its sign-in passthrough after the request handler returns, outside any +transaction the handler could open, so an audit write cannot be enlisted with the +token-store insert on the public-grant path. Minting through the token manager in +an IDMT-owned transaction is what lets the audit row and the token row commit or +roll back together. The wire-level RFC 8693 grant is therefore not registered; +the `act`-claim semantics are kept, the public grant is not. + +The flow has fixed properties: + +- The system user must hold an active `SysRole` capability, and the uniform + `TenantAccess` gate still applies to the target tenant. Both checks run inside + the mint, before the token is created. +- The audit record is written in the same transaction as the token-store insert, + before the token is returned, so there is no window where a support token + exists without an audit row. The prototype proved this against real + infrastructure: the OpenIddict Entity Framework Core store resolves the same + scoped `DbContext`, so its insert enlists in IDMT's transaction, and a forced + audit-write failure rolls back the already-persisted token. +- No refresh token is issued. When the support token expires, the system user + must mint again, and each mint is audited. +- The token's lifetime is bounded by a TTL ceiling. A consumer can lower the + ceiling but cannot raise it. +- A `SupportSession` authorization policy lets a tenant endpoint detect that the + caller is an impersonating system user and refuse destructive operations or + surface a banner. + +### 2.9 The opinionated and customizable seam + +This is the central design problem, and the rule is structural. Security +invariants are locked and applied unconditionally; shape and surface are open. + +A customizable security library fails when a consumer customizes away a security +property without noticing. v2 prevents this by applying the locked behavior +inside the builder's `Build()` step regardless of what the consumer called, and +by making the locked set additive-only in the type system. A consumer can add +behavior; a consumer cannot subtract a security property. + +The locked set, enforced in `Build()`: + +- The uniform `TenantAccess` gate, applied at token issuance for every grant and + at every server-side support-token mint. +- Reference access tokens **with `EnableTokenEntryValidation()` and the co-hosted + local validation handler**, so revocation is enforced per request + ([§2.3](#23-openiddict-as-the-protocol-engine)). +- Refresh-token rotation with reuse detection. +- The IDMT-owned per-request audience validation handler that binds a token to + the Finbuckle-resolved tenant ([§2.6](#26-multi-tenancy-integration)). +- The `SecurityStamp`-change propagation hook that revokes a user's tokens — + `RevokeBySubjectAsync` for a full credential change, `RevokeByAuthorizationIdAsync` + on the per-tenant authorization for a single-tenant revoke + ([§2.7](#27-canonical-identity-carried-from-adr-0001)). +- The support-token TTL ceiling. +- Audited support, with a required reason. +- A second authentication factor for system users and for users with access to + more than one tenant (see the MFA rule below). +- Cross-site request forgery protection on the backend-for-frontend session + (`SameSite` cookie plus an anti-forgery token), whenever the session surface is + enabled ([§2.5.1](#251-browser-clients-use-a-backend-for-frontend-session)). + +The open set, exposed as named extension points: + +- Claims enrichment that adds claims after the gate has run. +- Tenant-resolution strategy (route, header, claim, base path, or custom). +- Multi-factor factor selection, subject to the locked rule that system users + must hold a second factor. +- Email transport and link generation. +- Additional authorization policies layered on the built-ins. +- Consumer endpoints mounted under the pre-attached policy groups. +- The store backend, through the `Idmt.Core` repository ports. + +The second-factor rule is a domain invariant in `Idmt.Core`, not a feature of the +opt-in `Idmt.Mfa` package. `Idmt.Mfa` supplies factor *implementations* (TOTP +now, WebAuthn later); the *requirement* that a system user or a multi-tenant user +must satisfy a second factor before a token issues lives in the core gate. The +fail-fast at `Build()` is scoped to deployments that can actually produce a +triggering user: when MFA enforcement is on (the default) and no factor provider +is registered, `Build()` throws only if the deployment maps the sys-admin surface +or permits multi-tenant membership. A purely single-tenant app with no sys-admin +surface never trips the check and does not pay the MFA-provider tax on day one. A +deployment that maps those surfaces and genuinely wants single-factor must opt out +explicitly, which makes the canonical-identity blast-radius risk a recorded choice +rather than an accident. + +The requirement keys on a user's tenant count, which can change after tokens are +issued. Granting a second `TenantAccess` to a previously single-tenant user +crosses the one-to-many boundary and makes the second factor newly required. +Crossing that boundary fires the [§2.7](#27-canonical-identity-carried-from-adr-0001) +revocation hook for the affected user, so the user's existing single-factor tokens +are dropped and the next token issuance enforces the second factor. + +One honesty caveat about enforcement: `Build()` applies the locked configuration +as the last-registered options post-configuration, so it overrides earlier +consumer configuration and stops *accidental* subtraction. C# dependency +injection cannot stop a consumer who *deliberately* re-registers options after +`AddIdmt` from disabling a locked property. To close that gap, IDMT registers an +`IStartupFilter` self-check that asserts the locked invariants at startup — +reference tokens on, `EnableTokenEntryValidation()` on, the audience handler and +the revocation hook registered, and an MFA provider present when required — and +throws if any is missing. The self-check reads the resolved options snapshot, so +it catches subtraction expressed as registration. It cannot catch a consumer who +mutates options at resolve time, for example through a custom +`IPostConfigureOptions` or an options decorator that runs after the snapshot. The +guarantee is therefore "inadvertent subtraction is impossible, and deliberate +subtraction of the registered options fails fast and is detectable," not +"subtraction is impossible." For defense in depth, the audience and revocation +invariants also self-verify inside their own handler execution rather than relying +on the startup snapshot alone. §4 tests the self-check with a hostile +post-`AddIdmt` override. + +Registration uses a fluent `IIdmtBuilder` rather than v1's positional delegate +parameters, so each seam is named and discoverable and the locked-versus-open +line is visible in the type system. + +### 2.10 Endpoint scaffolding + +The scaffolding is the payoff for "opinionated but customizable." Two mapping +entry points hand the consumer route groups with the correct authorization +already attached. + +`MapIdmtTenantApi` mounts the tenant-facing surface (account self-management, +email flows, tenant membership) with the tenant policy and rate limiter attached. +`MapIdmtSysAdminApi` mounts the system-admin surface (tenant lifecycle, +`TenantAccess` grant and revoke, system-role assignment, and the support +exchange) with `RequireSysAdmin` attached. Both return the route group so a +consumer adds their own endpoints under the same pre-authorized umbrella. The +policy names are public constants: `RequireSysAdmin`, `RequireSysUser`, +`RequireTenantManager`, `RequireTenantMember`, and `SupportSession`. + +## 3. Bring-up plan + +v2 is a greenfield rewrite. There is no production data to carry and no installed +base to cut over, so this is a bring-up plan, not a data-migration plan. v2 stands +up a new persistence layer from scratch and seeds the registrations a running +authorization server cannot start without. + +The persistence layer is two Entity Framework Core contexts with two independent +migration histories. The multi-tenant application context holds the canonical +identity tables (`IdmtUser`, `IdmtRole`, `TenantAccess`, the system-role +assignment, the email-change staging, and the support-audit tables). A separate, +tenant-agnostic context holds the OpenIddict application, authorization, scope, +and token stores, for the reasons fixed in +[§2.6](#26-multi-tenancy-integration). v2 has no `RevokedToken` table; the +OpenIddict token store is authoritative for revocation. + +You generate the initial schema with the Entity Framework Core tools, one +migration per context: + +```bash +dotnet ef migrations add InitialCreate --context IdmtDbContext +dotnet ef migrations add InitialCreate --context IdmtOpenIddictDbContext +dotnet ef database update --context IdmtDbContext +dotnet ef database update --context IdmtOpenIddictDbContext +``` + +A running authorization server is non-functional without seeded OpenIddict +registrations, so IDMT supplies an `IIdmtApplicationSeeder` that provisions them +idempotently on startup. The seeder registers the default first-party client +applications, with their redirect URIs and PKCE enabled, and the scope catalog +the deployment uses, including the `support` scope that minted support tokens +carry. Consumers register their own clients, such as a single-page app's +redirect URIs, through the same seeder. The seeder also bootstraps the first +system administrator — an initial `IdmtUser` with a system-role assignment, sourced +from configuration on first run — because the sys-admin surface in +[§2.10](#210-endpoint-scaffolding) requires `RequireSysAdmin`, so without a seeded +first admin no one can grant `SysRole` to anyone and the system is locked out of +its own administration. + +For development and testing, the seeder runs against the ephemeral SQLite database +the integration-test stack already uses, seeding a test client, test tenants, and +a seeded system administrator. Idempotency lets it run on every startup without +duplicating registrations. + +## 4. Test strategy + +The locked decisions in [§2.9](#29-the-opinionated-and-customizable-seam) are +only real if tests enforce them. CI must gate on the following, and every locked +invariant maps to an entry here. + +- **Architecture fitness function.** `Idmt.Core` references no infrastructure + assembly. Vendor types appear only in their owning folder. +- **Route-mutation fuzzer.** Authenticate for tenant A, mutate the tenant route + segment to every other known tenant, and assert 403. This gates merges and + exercises the real audience validation handler from §2.6. +- **`TenantAccess` gate, parametric.** For every grant type, including refresh, + and for every server-side support-token mint, a user with no or expired + `TenantAccess` is denied a token. +- **Reference-token instant revocation.** With `EnableTokenEntryValidation()` and + the local validation handler configured, mint a token, revoke it, and assert + the next request returns 401 before the token's TTL expires. The test runs + against the configured handler, not a mocked store. +- **Refresh reuse detection.** Rotate a refresh token, replay the consumed one, + and assert the request is rejected and the token family is revoked. +- **Cross-grant audience isolation.** Present a tenant-A refresh token at + `/connect/token` resolving tenant B, and assert rejection. +- **Support audit atomicity.** Simulate an audit-write failure during a + support-token mint and assert neither the token nor the audit row survives + (the shared transaction rolls back the already-persisted token). +- **Support TTL cap.** Request a lifetime above the ceiling and assert the issued + token expires at or below the ceiling. +- **Cross-tenant token rejection.** Use a tenant-A token against a tenant-B route + and assert 401 from the audience handler. +- **`SecurityStamp` propagation.** Rotate a user's `SecurityStamp` and assert all + of that user's reference tokens return 401 on the next request. +- **MFA-required issuance.** With enforcement on, assert no token issues for a + system user or a multi-tenant user that has not satisfied a second factor. +- **Authorize-cookie tenant isolation.** Assert an authorize-endpoint sign-in + cookie minted for tenant A cannot be replayed against tenant B. +- **Backend-for-frontend session isolation.** Assert a session cookie minted for + tenant A cannot drive a tenant-B resource request, and that the session resolves + to a reference token validated by the same audience handler a raw bearer request + uses (no second validation path). +- **Backend-for-frontend CSRF.** With the session surface enabled, assert a + cross-site request that carries the session cookie but no anti-forgery token is + rejected. +- **No token in the browser.** Assert the single-page-app login response sets only + the `httpOnly` session cookie and returns no access or refresh token to the + client. +- **Configuration integrity.** Register a consumer post-configuration after + `AddIdmt` that disables a locked property, and assert the startup self-check + throws. +- **OAuth 2.1 posture.** Assert the password grant is not configured or exposed. + +## 5. Consequences + +This section records what you gain, what you take on, and how you contain the +new risks. + +### 5.1 Positive + +The audit backlog closes by construction rather than by a long checklist: +revocation, rotation, and session coherence come from the engine. The library +surface shrinks, because login, refresh, revocation, and token-revocation +bookkeeping are owned by OpenIddict, not IDMT. Support, revocation, expiry, and +audience all +run through one token code path, so there is one set of invariants to test. + +### 5.2 Risk and mitigation + +The canonical-identity model concentrates blast radius: one stolen credential +reaches every tenant the user belongs to. We mitigate by requiring a second +factor for system users and for multi-tenant users, enforced as a core domain +invariant with a fail-fast startup check +([§2.9](#29-the-opinionated-and-customizable-seam)), so the mitigation cannot be +silently absent. Reference tokens add a store read per request, and instant +revocation degrades to cache-lifetime revocation across scaled-out instances +without a revocation backplane (Redis publish-subscribe or database polling); we +treat the backplane as a near-term requirement, not a deferred nicety, because +support-token revocation latency is itself a security property. Coupling to +OpenIddict is contained because the engine is named in one package behind a port. +The Finbuckle and OpenIddict reconciliation is the sharpest risk and is addressed +by the dedicated tenant-agnostic store context +([§2.6](#26-multi-tenancy-integration)), the per-request audience handler, and +the route-mutation fuzzer, and it is proven by the +[§7](#7-prototype-gate-and-open-questions) prototype gate before ratification. + +## 6. Alternatives considered + +Each alternative below was rejected for a specific reason, recorded so the +decision is auditable later. + +| Alternative | Why rejected | +|---|---| +| Keep hand-rolling auth | The remaining backlog is an identity provider; building it competes with hardened engines on commodity work. | +| Keycloak | A JVM service is a foreign runtime in an all-.NET foundation, and it adds a second tenancy model to reconcile. | +| Duende IdentityServer | Commercial license above a revenue threshold, which multiplies across many products. | +| Managed B2B identity provider | The owner requires owning the infrastructure; managed hosting is out. | +| ABP framework | A whole-application framework is the opposite of a thin plugin and imposes its architecture on every product. | +| Five-package split | Cleaner vendor-version blast radius, but more ceremony than a solo-owned "simple plugin" warrants. The architecture test recovers the domain-isolation benefit at three packages; the vendor blast radius is the consciously accepted cost (see [§2.2](#22-module-boundaries-three-packages)). | +| Single package | Simplest to ship, but relies on convention to keep vendor types out of the domain. | +| Keep the cookie-or-bearer hybrid | Retains the dual-path bug class for no greenfield benefit. | +| Single-page app holds the token in the browser | Auth-code with PKCE and a token in JavaScript memory is spec-legal, but the token stays cross-site-scripting-reachable. The backend-for-frontend session ([§2.5.1](#251-browser-clients-use-a-backend-for-frontend-session)) is strictly safer and, given the locked co-hosting, nearly free. | +| Resource-owner password grant | Removed by OAuth 2.1; building login on it is a dead end. | +| Expose RFC 8693 token exchange as a public grant for support tokens | The grant pipeline creates the token through sign-in passthrough after the request handler returns, so the support audit write cannot share the token-store transaction. Minting server-side through the token manager keeps the audit atomic; the prototype confirmed it ([§2.8](#28-system-support-through-a-server-side-token-mint)). | + +## 7. Prototype gate and open questions + +Two reviews — an adversarial critic and a validating architect — confirmed that +several load-bearing claims about how OpenIddict, Finbuckle, and Entity Framework +Core compose could not be settled on paper. A prototype spike was required before +this ADR moved from Proposed to Accepted; it passed (see the prototype outcome in +[§7.0](#70-prototype-gate-precondition-to-ratification)). The open questions in +§7.1 remain genuinely open and must not be settled silently during implementation. + +### 7.0 Prototype gate (precondition to ratification) + +A single spike must prove, end to end on .NET 10 with the applicable OpenIddict +version, that: + +1. Reference tokens with `EnableTokenEntryValidation()` revoke on the next + request through the configured local validation handler. +2. The server-side support-token mint — through `IOpenIddictTokenManager.CreateAsync` + inside an IDMT-owned transaction, not a public token-exchange grant — re-runs + the `TenantAccess` gate and writes the audit row in the **same transaction** as + OpenIddict's token-store insert, so a token can never commit without its audit + row. The prototype confirmed the OpenIddict Entity Framework Core store resolves + the same scoped `DbContext`, so its insert enlists in the owned transaction, and + a forced audit-write failure rolls back the already-persisted token. This was + the unproven part; it is now proven. +3. A per-request handler rejects a token whose `aud` does not equal the + Finbuckle-resolved tenant. +4. OpenIddict's stores in a separate, tenant-agnostic `DbContext` coexist with + Finbuckle's save-side `TenantId` stamping, and the token endpoint reads and + writes tokens with no ambient tenant. +5. A hostile consumer override registered after `AddIdmt(...)` fails the startup + self-check. +6. The `SecurityStamp`-change hook revokes a user's tokens. The prototype showed + the cleanest mechanism is two single store calls, not a manual enumeration: + `RevokeBySubjectAsync` for a full credential change, and + `RevokeByAuthorizationIdAsync` on a per-(user, tenant) authorization for a + single-tenant revoke. A token entry records no audience to filter on (the + audience lives only in the encrypted payload), which is why single-tenant + revocation uses authorization grouping rather than an audience filter. Proven + against a 100-token user; cost does not scale with tokens held. +7. A backend-for-frontend session cookie resolves to its **server-side** reference + token and runs the same per-request audience handler a raw bearer request runs, + so the cookie path and the bearer path share one validation, and a mutating + request bearing the session cookie but no anti-forgery token is rejected. + +If items 1 through 4 do not compose cleanly, the "own the policy, rent the +protocol" cost basis must be re-evaluated before the rewrite begins. + +**Prototype outcome.** All seven items passed on .NET 10 with OpenIddict 7.5.0, +Finbuckle.MultiTenant 10.0.3, and SQLite, plus a follow-on gate 8 that proved the +real browser-login flow (19 tests total). Corrections and scoped stand-ins the +spike surfaced, folded into this ADR: + +- §2.7 is corrected: OpenIddict 7.5.0 *does* expose `RevokeBySubjectAsync`, and a + token entry has no audience column. Single-tenant revocation is by authorization + grouping (item 6). +- Support tokens mint server-side, not through a public token-exchange grant + ([§2.8](#28-system-support-through-a-server-side-token-mint), item 2). +- Gate 5 proves the two-layer lock plus detection of registration-expressed + subtraction; resolve-time mutation remains uncatchable, as + [§2.9](#29-the-opinionated-and-customizable-seam) already concedes. +- Gate 7 proves server-side session resolution, the shared validation path, and + anti-forgery rejection. +- Gate 8 proves the real browser login: authorization code + PKCE (enforced, not + decorative) through an interactive authorization-server session, the BFF + exchanging the code server-side and storing the reference token in the session. + The issued token's **subject is the authenticated user** — this supersedes gate + 7's client-credentials back-channel stand-in (subject = client) and resolves the + §7.1 first-party-auth question. The remaining stand-in scope is small: the + single-instance topology proves revocation correctness, not the bounded-staleness + scale-out window (§7.1 backplane), and a real cross-site `SameSite` redirect was + not exercised in-process. + +### 7.1 Open questions + +The following remain undecided and are tracked separately from the gate. + +- **Machine-client authentication** without the password grant. The browser flow + is settled: gate 8 proved **authorization code + PKCE** with a server-side BFF + session, so interactive login is no longer open. What remains is the + machine-to-machine choice (client credentials, as the spike's resource clients + use, and/or a code exchange) for non-interactive callers. +- **Out-of-process resource servers.** v2 assumes the resource API is co-hosted + with the OpenIddict server so the local validation handler enforces revocation + ([§2.3](#23-openiddict-as-the-protocol-engine)). Decide whether to support a + split deployment at all, and if so whether introspection without response + caching is an acceptable revocation story. +- **Reference-token revocation backplane** transport at scale-out: Redis + publish-subscribe versus database polling. +- **Per-tenant signing keys.** The default is a single issuer with tenant as + audience. Revisit only if hard cryptographic tenant isolation becomes a + requirement. +- **Multi-factor factors and timeline.** Decide TOTP versus WebAuthn and the + rollout, given that the *requirement* for a second factor is already locked in + [§2.9](#29-the-opinionated-and-customizable-seam). + +## 8. References + +The following artifacts informed this decision and contain the detailed design +and scoring behind it. + +- `adr/0002-v2-sketch-dotnet-expert.md` — .NET specialist sketch (fluent builder, + "own the policy, rent the protocol"). +- `adr/0002-v2-sketch-architect-reviewer.md` — bounded-context decomposition and + the locked-versus-open security seam. +- `adr/0002-v2-sketch-code-architect.md` — v1-to-v2 file migration map and a + concrete support-exchange slice. +- `adr/0002-v2-evaluation.md` — the scored comparison and the chosen hybrid. +- `adr/0001-canonical-identity-and-tenant-access.md` — the identity model this + ADR keeps and the `ServerSession` and sys-switch design it supersedes in part. +- `SECURITY_AUDIT.md` — the findings that motivated the rewrite. +- OpenIddict token-manager documentation — `IOpenIddictTokenManager.CreateAsync` + for server-side token creation (the support-token mint path). +- OpenIddict token-storage documentation — reference tokens and + `EnableTokenEntryValidation()` (per-request revocation is opt-in and required + with reference tokens). +- OpenIddict token-validation guide — local validation handler versus + introspection. +- RFC 8693 (token exchange), RFC 8707 (resource indicators), RFC 7009 (token + revocation), RFC 6749 (OAuth 2.0). +- Finbuckle.MultiTenant documentation — tenant resolution and query filters. diff --git a/adr/0002-v2-evaluation.md b/adr/0002-v2-evaluation.md new file mode 100644 index 0000000..b9f9500 --- /dev/null +++ b/adr/0002-v2-evaluation.md @@ -0,0 +1,142 @@ +# ADR 0002 — Evaluation of the three v2 sketches + +- **Status:** Decision aid +- **Date:** 2026-06-04 +- **Author:** Claude (synthesis pass) +- **Reads:** `0002-v2-sketch-dotnet-expert.md`, `0002-v2-sketch-architect-reviewer.md`, `0002-v2-sketch-code-architect.md` + +> Purpose: score the three sketches against the owner's *stated* goals, surface +> where they actually disagree (most of it is the same design), and give a +> recommendation with a concrete way to combine them. This is not a tie-breaker +> vote — it's a map of which sketch is right about what. + +--- + +## 0. The goals being scored against + +Pulled from the owner's own words across the discussion: + +1. **Simple** "plugin" library/service. +2. **Perfect balance: opinionated ↔ customizable.** +3. **Secure** (the audit backlog must structurally close, not be re-implemented). +4. **Multi-tenant** capable. +5. Library consumer can build **endpoints for both the tenant side and the sys-admin side.** +6. **Sys-admin supports a tenant cleanly** — no account duplication. +7. **Own the infra**, pure .NET, self-hosted (OpenIddict chosen; no Keycloak/Duende/managed). +8. Greenfield, low sunk cost (AI-written, cheap to rewrite). + +--- + +## 1. Where all three already agree (the settled core) + +Treat this as decided — three independent passes converged on it: + +- **OpenIddict owns the OAuth/OIDC protocol**; IDMT owns the multi-tenant authorization model + endpoint scaffolding. ("Own the policy, rent the protocol.") +- **Reference (opaque) access tokens** are the default/locked choice → instant revocation. This is the single biggest security win and it deletes the entire C1/N5/M2/`TokenRevocationService` backlog. +- **Sys-support = RFC 8693 token exchange** minting a tenant-scoped, time-bound, **audited** token. No shadow rows. All three carry the actor/`support_of`/`support_invoker` claim and write audit *before* returning the token. +- **`ValidateBearerTokenTenantMiddleware` dies**, replaced by token-tenant binding (audience or a validation handler). +- **Canonical `IdmtUser` + `TenantAccess` + `SysRole` + Finbuckle + ASP.NET Identity user store** all survive. The uniform TenantAccess gate stays, now also enforced at token issuance. +- **`AddIdmt` + `Map*` endpoint scaffolding** with policies pre-attached; `ErrorOr` + FluentValidation + vertical slices for the remaining business endpoints. +- **DP key persistence / fail-fast on prod keys** called out by all three. + +If the sketches agree on it, it's low-risk. The decision is really about the **three axes where they diverge**. + +--- + +## 2. The three real disagreements + +### Axis A — Package granularity + +| Sketch | Shape | Stance | +|---|---|---| +| dotnet-expert | **5 packages**: Abstractions ← Core ← {Server, Persistence, Mfa} | OpenIddict isolated in `Idmt.Server`; Abstractions has zero infra deps | +| architect-reviewer | **4 (+1 persistence) assemblies**: Core, MultiTenancy, OpenIddict, AspNetCore, Persistence.EF | Each vendor (OpenIddict/Finbuckle/EF) named in *exactly one* assembly, enforced by an architecture-test fitness function | +| code-architect | **1 package** (`Idmt.Plugin`), optional Abstractions later | Keep v1's single-package shape; minimize consumer migration surface | + +This is the **central tension vs. goal #1 (simple)**. More packages = cleaner blast radius and compile-time firewalls (reviewer's strongest argument: a Finbuckle or OpenIddict major bump touches one package), but more ceremony for a solo owner shipping a "simple plugin." Fewer packages = simpler to ship and reason about, but vendor types can leak across folders by accident (exactly how v1's `GrantTenantAccess.cs` ended up doing shadow-row surgery). + +### Axis B — The cookie / auth model + +| Sketch | Stance | +|---|---| +| dotnet-expert | **Drops per-tenant cookies for APIs entirely.** Cookies survive only for the interactive sign-in UI; all API traffic is bearer reference tokens. Collapses v1's hybrid cookie/bearer complexity. | +| architect-reviewer | Same direction — cookies become a "thin first-party-client convenience over the same token store, not a parallel auth universe." | +| code-architect | **Keeps** the `CookieOrBearer` PolicyScheme and per-tenant cookie isolation; bearer simply forwards to OpenIddict's validation scheme. | + +This is a genuine fork. expert/reviewer argue the hybrid model is a bug-class generator ("cookie path vs bearer path diverge") and should go. code-architect argues for continuity and minimal migration. **Goal #3 (secure) leans expert/reviewer; goal #1/#8 lean code-architect.** + +### Axis C — Migration philosophy + +| Sketch | Stance | +|---|---| +| dotnet-expert | Greenfield-leaning; new package names, fluent builder, force re-auth at cutover | +| architect-reviewer | Greenfield decomposition; explicitly notes schema is *largely preserved* so data migration is lower-risk than ADR-0001's reshape | +| code-architect | **Maximum continuity**: identical `AddIdmt` signature (adds one optional `CustomizeOpenIddict` delegate), file-by-file fate map, ~70% of code KEPT | + +Given goal #8 (cheap to rewrite, greenfield, low sunk cost), continuity is *less* valuable than it looks — the owner explicitly said preserving v1 is not a constraint. + +--- + +## 3. Scorecard + +Scored 1–5 against each goal (5 = best serves it). These are judgments, not arithmetic truth — read the reasoning, not the totals. + +| Goal | dotnet-expert | architect-reviewer | code-architect | +|---|:---:|:---:|:---:| +| 1. Simple plugin | 3 | 2 | **5** | +| 2. Opinionated ↔ customizable | **5** (fluent builder + typed escape hatch) | **5** (structurally locked in `Build()`) | 3 (keeps v1's positional-delegate soup + new delegate) | +| 3. Secure (closes backlog structurally) | 4 | **5** (locked/open line is the sharpest; fitness functions) | 4 | +| 4. Multi-tenant | 4 | **5** (names Finbuckle↔OpenIddict reconciliation as *the* risk; aud = source of truth + route-mutation fuzzer) | 4 (concrete `TenantValidationHandler`, but middleware-style) | +| 5. Tenant + sys endpoints | 4 | **5** (`IdmtTenantEndpoints`/`IdmtSystemEndpoints` expose sub-groups for consumer endpoints) | 4 (real mapper code, less explicit on consumer-extension seam) | +| 6. Clean sys-support | 4 | **5** (support token = just a tenant-audienced reference token; one code path, deletes `IsSysSession` branch) | **5** (full working slice; most concrete) | +| 7. Own infra / pure .NET | 5 | 5 | 5 | +| 8. Greenfield value | 4 | 4 | 3 (optimizes for a migration the owner doesn't need) | +| **Distinctiveness / depth** | builder ergonomics + AOT realism | decomposition rigor + fitness functions | runnable concreteness + file-level map | + +**読み (read):** +- **architect-reviewer** is strongest on the things that bite later: the opinionated/customizable line (goal #2) drawn *structurally* so a consumer can't subtract a security property, the tenancy-reconciliation risk (goal #4) named and guarded with a CI fuzzer, and the cleanest sys-support model (support token is not a special object). Weakest on goal #1 — five assemblies is a lot of ceremony for a solo "simple plugin." +- **dotnet-expert** is the best *.NET-idiomatic* design: the fluent `IIdmtBuilder`, the "wrap OpenIddict, don't hide it, consumer-wins-last" escape hatch, and the only sketch honest about AOT being out of reach. Middle on simplicity. +- **code-architect** is the most *actionable*: a real `SupportTenant.cs` you could almost compile, a file-by-file fate map, and the lowest-friction path. But it optimizes for migration continuity (goal #8 says you don't need that) and keeps the `CookieOrBearer` hybrid + positional-delegate registration that the other two deliberately kill. + +--- + +## 4. Recommendation: a hybrid, biased to architect-reviewer's spine + +No single sketch is the answer. The right v2 is **architect-reviewer's boundaries and locked/open security model, dialed down on package count toward code-architect's pragmatism, with dotnet-expert's fluent builder as the registration surface.** Concretely: + +1. **Boundaries from architect-reviewer, but 3 packages not 5.** Collapse to: + - `Idmt.Core` — domain (IdmtUser/TenantAccess/SysRole, policies, `ISupportPolicy`, ports). Zero infra. *Keep this boundary hard — it's the firewall that stops the next `GrantTenantAccess.cs`.* + - `Idmt.AspNetCore` — composition root: OpenIddict + Finbuckle + EF + endpoints + MFA + email, organized in folders (Server/, MultiTenancy/, Persistence/, Endpoints/). Vendors live here, isolated by folder + the one architecture-test. + - `Idmt.Mfa` — opt-in (keeps the fido2 dependency off the main package). + + This honors goal #1 (a consumer adds **one** package, `Idmt.AspNetCore`) while keeping the *one* boundary that actually matters (Core can't see infra). Adopt the **`Idmt.Architecture.Tests` fitness function** regardless of package count — it's cheap insurance against leakage. + +2. **Security model: architect-reviewer's LOCKED/OPEN line verbatim** (§4 of that sketch). This *is* the answer to goal #2. Reference tokens, the gate, refresh rotation, support-TTL ceiling, audience isolation, audited support → locked in `Build()`, not configurable. Claims/MFA-factors/transport/extra-endpoints → open. This is the single most important idea across all three docs. + +3. **Registration surface: dotnet-expert's fluent `IIdmtBuilder`** (not code-architect's positional delegates — that's the one place code-architect's continuity bias actively hurts). Fluent + named seams makes the locked/open line *visible in the type system*. + +4. **Sys-support: architect-reviewer's "just a tenant-audienced reference token"** model, implemented with **code-architect's concrete slice** as the starting code. Best of both — right abstraction, runnable shape. + +5. **Cookie model: side with expert/reviewer — kill the hybrid.** APIs are bearer reference tokens; cookies only for the interactive sign-in surface if you even keep one. This closes a whole bug class and you're greenfield, so there's no migration cost to fear (goal #8). + +6. **Endpoint scaffolding: architect-reviewer's `IdmtTenantEndpoints`/`IdmtSystemEndpoints`** returning sub-groups, so the consumer mounts their own tenant-side and sys-side endpoints under the pre-attached policy (goal #5). dotnet-expert's `RouteGroupBuilder` return is equivalent and simpler — either works. + +--- + +## 5. The open questions you must resolve before building (all three raised these) + +These are not sketch-specific — they're real and a prototype should de-risk them **first**: + +1. **Finbuckle global query filters vs. OpenIddict EF stores.** If the multi-tenant query filter touches the OpenIddict token tables, token validation breaks silently. Keep OpenIddict tables out of the tenant filter. *Prototype this composition first — it's the #1 integration risk and every sketch flagged it.* +2. **Tenant resolution for the token endpoint.** Route-based `/{tenant}/connect/token` breaks standard OIDC discovery; header/`resource`-based is cleaner but needs IDMT-aware clients. Pick one, document the OIDC-conformance tradeoff. +3. **`password` grant + OAuth 2.1.** It's being removed. Don't build login on the password grant; use authorization-code + PKCE (or a custom credential-exchange that issues a code). code-architect §7.2 is right to flag this — decide early because it shapes the login slice. +4. **Reference-token read amplification + multi-instance revocation.** One DB read per request; "instant" revocation degrades to cache-TTL across scaled-out instances without a backplane (Redis pub/sub or DB polling). dotnet-expert calls this the #1 production risk. Decide your scale-out story before committing to a cache. +5. **Per-tenant signing keys?** All three say: single issuer, tenant-as-claim/audience, one trust domain. Only revisit if hard cryptographic tenant isolation becomes a requirement. + +--- + +## 6. One-line verdict + +> Build **architect-reviewer's bounded, locked/open design** with **dotnet-expert's fluent builder**, packaged at **code-architect's lower ceremony (≈3 packages)**, and seed the sys-support slice from **code-architect's concrete `SupportTenant.cs`** — but prototype the Finbuckle×OpenIddict EF-store composition *before* writing anything else. + +The most valuable single idea in the whole set: **architect-reviewer's "security invariants are locked and additive-only; the type system makes subtraction impossible."** That sentence is the answer to "opinionated but customizable." diff --git a/adr/0002-v2-sketch-architect-reviewer.md b/adr/0002-v2-sketch-architect-reviewer.md new file mode 100644 index 0000000..46740f8 --- /dev/null +++ b/adr/0002-v2-sketch-architect-reviewer.md @@ -0,0 +1,502 @@ +# ADR 0002 — IDMT v2 Architecture Sketch (architect-reviewer) + +- **Status:** Draft / for comparison +- **Date:** 2026-06-04 +- **Author:** architect-reviewer +- **Scope:** Greenfield v2 layout sketch. Design artifact only, no implementation. +- **Relates to:** ADR-0001 (canonical identity), `SECURITY_AUDIT.md` + +> This is one of three parallel architect sketches. It is written to be read +> side-by-side against the others. It is deliberately opinionated. The central +> bet: **v2 should own a multi-tenant *authorization* model and stop owning an +> *authentication server*.** OpenIddict is the token engine; IDMT is the +> tenancy-and-authorization layer wrapped around it, plus the endpoint +> scaffolding that makes both the tenant side and the sys-admin side trivial to +> stand up. + +--- + +## 0. Framing: what changed, and the one architectural insight + +ADR-0001 set out to hand-build the hard parts of an identity provider: +server-side opaque sessions for instant revocation, a `/sys-switch` flow with +step-up and time-bound elevation, per-(user,tenant) lockout, audit shipping. All +of that is correct *as requirements*. The v2 insight is that **most of it is +commodity IdP machinery that OpenIddict already provides**: + +| ADR-0001 hand-rolled mechanism | OpenIddict native equivalent | +|---|---| +| `ServerSession` table + 30s cache + opaque cookie id | **Reference (opaque) access tokens** — token data lives server-side, the wire value is a lookup handle. Revoke = delete one row. | +| `/sys-switch` minting a scoped, time-bound, audited session | **Token exchange (RFC 8693)** — trade a sys token for a tenant-scoped, short-lived, fully-auditable token. | +| `UpdateSecurityStampAsync` invalidating all sessions | **Bulk token revocation by subject** in the OpenIddict token store. | +| Hand-rolled refresh + bearer expiry | OpenIddict **refresh-token rotation** with reuse detection. | + +So v2's job shrinks to the part that is genuinely *ours* and not commodity: + +1. The **multi-tenant authorization model** (`TenantAccess`, `SysRole`, role + resolution per tenant). +2. The **mapping** from that model onto OpenIddict's token issuance (which + claims/scopes/audiences go into a token, and *whether a token may be issued + at all* — the TenantAccess gate). +3. **Endpoint scaffolding** so a consumer can mount a tenant-facing surface and + a sys-admin-facing surface with the right policies pre-attached. + +Everything else we delete (see §7). + +--- + +## 1. Bounded contexts / module boundaries + +I decompose v2 into **five bounded contexts**. The decomposition is driven by +the *dependency rule* (dependencies point inward toward the domain) and by +*blast radius* (a change to OpenIddict's API, or to Finbuckle, should be +absorbable in one assembly). + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ Context A — Identity & Access Domain (the part that is OURS) │ +│ Canonical IdmtUser, SysRole, TenantAccess, TenantRole. │ +│ Pure model + invariants. No EF, no HTTP, no OpenIddict types. │ +├───────────────────────────────────────────────────────────────────┤ +│ Context B — Multi-Tenancy Resolution │ +│ Tenant identity, tenant store, route/header/claim resolution. │ +│ Owns the Finbuckle seam. Knows nothing about tokens. │ +├───────────────────────────────────────────────────────────────────┤ +│ Context C — Authorization Server Integration │ +│ The OpenIddict seam. Token issuance, scopes, reference tokens, │ +│ refresh rotation, token exchange. The ONLY context that names │ +│ OpenIddict types. │ +├───────────────────────────────────────────────────────────────────┤ +│ Context D — Support / Impersonation │ +│ Sys-user "support a tenant" via token exchange. Audit. Policy. │ +│ Depends on A (capability check) + C (mint exchange token). │ +├───────────────────────────────────────────────────────────────────┤ +│ Context E — Endpoint Scaffolding & Composition │ +│ The consumer-facing surface. AddIdmt, MapIdmt*, policy contract, │ +│ MFA, rate limiting, email flows. Composes A–D. │ +└───────────────────────────────────────────────────────────────────┘ +``` + +**What is a separate assembly vs. a folder, and why.** + +The rule I apply: *an assembly boundary exists where I want an independent +substitution point, an independent test surface, or an independent compile-time +guarantee about what an inner layer may reference.* A folder is enough when the +only goal is organization. + +| Context | Packaging | Why | +|---|---|---| +| A — Identity & Access Domain | **Separate assembly** `Idmt.Core` | The domain must be testable with zero infrastructure and must *not be able to* reference EF/OpenIddict/Finbuckle. An assembly boundary makes that a compile-time guarantee (the package simply doesn't reference them), not a code-review convention. This is the dependency-rule firewall. | +| B — Multi-Tenancy | **Separate assembly** `Idmt.MultiTenancy` | Finbuckle is a swap candidate (the owner might one day want path-based-only, or a custom resolver). Isolating it means a Finbuckle major-version bump or replacement is a one-package blast radius. Also independently testable against an in-memory tenant store. | +| C — Auth-Server Integration | **Separate assembly** `Idmt.OpenIddict` | This is the single most coupled-to-a-vendor context and the one most likely to churn (OpenIddict releases, OAuth spec nuances). It must be the *only* place that `using OpenIddict.*`. An assembly boundary is the firewall that keeps OpenIddict types from leaking into handlers and tests. | +| D — Support/Impersonation | **Folder inside `Idmt.OpenIddict`**, with its *contract* in `Idmt.Core` | The token-exchange mechanics are inseparable from OpenIddict, so the implementation lives with C. But the *policy* ("who may support whom, for how long, with what reason") is domain and lives in A. This split is deliberate: the dangerous capability is governed by domain rules that are unit-testable without OpenIddict. | +| E — Scaffolding & Composition | **Separate assembly** `Idmt.AspNetCore` (the NuGet consumers reference) | This is the only package most consumers add. It transitively pulls A–D. Keeping it thin and composition-only means the "opinionated defaults" live in one auditable place. | + +Net packaging: **four shipped assemblies** (`Idmt.Core`, `Idmt.MultiTenancy`, +`Idmt.OpenIddict`, `Idmt.AspNetCore`) plus a persistence assembly (below). +Fewer than this and OpenIddict leaks into the domain; more than this and we are +gold-plating. EF lives in its own `Idmt.Persistence.EntityFrameworkCore` +implementing repository interfaces declared in `Idmt.Core`, so the store is +swappable and the domain never sees `DbContext`. + +> **Distinctive choice #1:** I do *not* keep v1's vertical-slice "static class +> per feature" as a layering primitive. Vertical slices are a *delivery* +> pattern; they belong inside `Idmt.AspNetCore` as the shape of the endpoint +> code, but they are not allowed to be the place where domain invariants or +> OpenIddict calls live. v1 conflated "feature folder" with "layer" and that is +> why `GrantTenantAccess.cs` ended up doing shadow-row surgery. v2 puts the +> invariant in the domain and the handler stays thin. + +--- + +## 2. Solution & project layout with dependency direction + +``` +Idmt.slnx +│ +├── src/ +│ ├── Idmt.Core/ ← Context A (domain). NO infra refs. +│ │ ├── Identity/ IdmtUser, SysRole, TenantAccess, TenantRole +│ │ ├── Authorization/ IdmtPolicies, ITenantAccessPolicy, capability rules +│ │ ├── Support/ ISupportPolicy (token-exchange *rules*, not mechanics) +│ │ ├── Abstractions/ repository + service interfaces (ports) +│ │ └── Results/ ErrorOr error catalog (IdmtErrors) +│ │ +│ ├── Idmt.MultiTenancy/ ← Context B. Refs: Core, Finbuckle. +│ │ ├── Resolution/ strategy wiring (route/header/claim/basepath) +│ │ ├── Store/ tenant store abstraction over IdmtTenantInfo +│ │ └── TenantContextAccessor bridges Finbuckle → Core's ICurrentTenant +│ │ +│ ├── Idmt.OpenIddict/ ← Context C+D impl. Refs: Core, MultiTenancy, OpenIddict. +│ │ ├── Server/ authorize/token/introspect/revoke/userinfo wiring +│ │ ├── Tokens/ reference-token config, refresh rotation, scopes +│ │ ├── ClaimsPipeline/ Core model → token claims (TenantAccess gate here) +│ │ └── Support/ RFC 8693 token-exchange handler (sys-support) +│ │ +│ ├── Idmt.Persistence.EntityFrameworkCore/ ← store impl. Refs: Core, EF, OpenIddict.EF stores. +│ │ ├── Contexts/ IdmtDbContext, IdmtTenantStoreDbContext +│ │ ├── Repositories/ implements Core.Abstractions ports +│ │ └── Migrations/ +│ │ +│ └── Idmt.AspNetCore/ ← Context E. The package consumers add. +│ ├── DependencyInjection/ AddIdmt(...) builder +│ ├── Endpoints/ MapIdmtTenant(), MapIdmtSystem(), MapIdmtAuthServer() +│ │ ├── Tenant/ login, manage, tenant membership (vertical slices) +│ │ └── System/ sys-user mgmt, support/exchange (vertical slices) +│ ├── Mfa/ TOTP / WebAuthn step-up on the token foundation +│ ├── RateLimiting/ edge limiter policy +│ └── Email/ confirmation/reset flows +│ +└── tests/ + ├── Idmt.Core.Tests/ pure domain, no infra + ├── Idmt.OpenIddict.Tests/ token issuance + gate + exchange + ├── Idmt.Architecture.Tests/ FITNESS FUNCTIONS (see §6) + └── Idmt.Integration.Tests/ WebApplicationFactory + SQLite, cross-tenant fuzzer +``` + +**Dependency direction (must hold; enforced as a fitness function):** + +``` + Idmt.AspNetCore (composition root) + / | \ \ + v v v v + Idmt.OpenIddict Idmt.MultiTenancy Idmt.Persistence.EF + \ | / + v v v + Idmt.Core ← depends on NOTHING of ours, no infra +``` + +- `Idmt.Core` references no other Idmt package and no infrastructure. +- `Idmt.OpenIddict` is the *only* package allowed to reference `OpenIddict.*`. +- `Idmt.MultiTenancy` is the *only* package allowed to reference `Finbuckle.*`. +- `Idmt.Persistence.EntityFrameworkCore` is the *only* package allowed to + reference `Microsoft.EntityFrameworkCore.*`. +- `Idmt.AspNetCore` is the composition root and the only package allowed to + reference ASP.NET Core hosting + all the others. + +The seams that make OpenIddict / Finbuckle / EF swappable and testable are, +respectively: the **ClaimsPipeline + token port** in C, the +**ICurrentTenant / tenant-store port** in B, and the **repository ports** in +the persistence package. Each vendor is named in exactly one assembly. + +--- + +## 3. Public API sketch (consumer-facing contracts) + +### 3.1 Registration surface + +The v1 `AddIdmt` with five positional `Action<>`/delegate parameters +is a smell — positional delegates are unergonomic and force the consumer to know +ordering. v2 uses a **builder** so each concern is named and discoverable, and so +"opinionated default" vs "extension point" is visible in the type system. + +```csharp +public static IdmtBuilder AddIdmt( + this IServiceCollection services, + IConfiguration configuration, + Action? configureOptions = null); + +// Fluent builder — every method is an explicit, documented seam. +public sealed class IdmtBuilder +{ + // Persistence: pick a store implementation (default EF Core). + IdmtBuilder UsePersistence(Action configure); + + // Multi-tenancy: tenant resolution strategies + store. + IdmtBuilder UseMultiTenancy(Action configure); + + // Auth server: OpenIddict is the default; swappable in principle. + IdmtBuilder UseAuthorizationServer(Action configure); + + // Security-critical knobs (MFA requirement, support TTL, token lifetimes). + IdmtBuilder ConfigureSecurity(Action configure); + + // Extension points — see §4 for the locked/open line. + IdmtBuilder AddClaimsEnricher() where T : class, IIdmtClaimsEnricher; + IdmtBuilder AddAuthorizationPolicies(Action extend); +} +``` + +`AddIdmt` returns a builder rather than `IServiceCollection` so that the +security-critical wiring (the TenantAccess gate, reference tokens, refresh +rotation) is applied *eagerly and unconditionally* inside the builder's +`Build()` and cannot be omitted by a consumer who forgets a call. The +extension hooks are *additive only* — they cannot remove a default. + +### 3.2 Endpoint scaffolding — tenant side and system side + +This is the "opinionated but customizable" payoff. Two mapping entry points, +each pre-attaching the correct authentication scheme and authorization policies. +The consumer chooses *which* surfaces to mount and where. + +```csharp +public static class IdmtEndpointRouteBuilderExtensions +{ + // The OAuth/OIDC server endpoints (OpenIddict-backed): authorize, token, + // introspection, revocation, userinfo. Mounted once. + static IEndpointConventionBuilder MapIdmtAuthorizationServer( + this IEndpointRouteBuilder app, Action? o = null); + + // Tenant-facing surface: login/logout, account self-management, email flows, + // tenant-membership management. All policies pre-attached. + static IdmtTenantEndpoints MapIdmtTenantApi( + this IEndpointRouteBuilder app, Action? o = null); + + // System-admin surface: sys-user CRUD, sys-role assignment, and the + // support/impersonation (token-exchange) endpoint. RequireSysAdmin pre-attached. + static IdmtSystemEndpoints MapIdmtSystemApi( + this IEndpointRouteBuilder app, Action? o = null); +} +``` + +The returned `IdmtTenantEndpoints` / `IdmtSystemEndpoints` expose the individual +route groups so a consumer can add their *own* endpoints under the same group +with the same pre-attached policy, or selectively disable a built-in: + +```csharp +var tenant = app.MapIdmtTenantApi(o => +{ + o.Membership.Enabled = true; // opinionated default: on + o.SelfService.Enabled = true; +}); +// Consumer composes their own endpoints under the SAME policy umbrella: +tenant.MembershipGroup.MapGet("/grants/pending", MyHandler) + .RequireAuthorization(IdmtPolicies.TenantManager); +``` + +### 3.3 Authorization policy contract + +Policies are exposed as **string constants on a public static surface** so they +are referenceable from consumer endpoints, and the *policy objects* are +registered by the builder. The consumer never re-declares them. + +```csharp +public static class IdmtPolicies +{ + public const string SysAdmin = "Idmt.SysAdmin"; + public const string SysUser = "Idmt.SysUser"; + public const string TenantManager = "Idmt.TenantManager"; + public const string TenantMember = "Idmt.TenantMember"; // new in v2: the gate baseline + public const string SupportSession = "Idmt.SupportSession"; // token-exchange-minted tokens +} +``` + +> **Distinctive choice #2:** v2 adds `TenantMember` and `SupportSession` as +> first-class policies. v1 only had role-shaped policies (SysAdmin/SysUser/ +> TenantManager) and relied on the login-time gate for membership. In v2 the +> gate also runs *at token-issuance* (§5), and `TenantMember` lets every +> tenant endpoint assert membership declaratively rather than implicitly. +> `SupportSession` lets endpoints distinguish a real member from an +> impersonating sys user — important for audit and for blocking destructive +> operations during support. + +--- + +## 4. The opinionated-vs-customizable seam (the central design problem) + +This is where I plant the flag. The failure mode of a "customizable security +library" is that a consumer customizes away a security property without +realizing it (v1 already had to special-case `SameSite=None` → force `Strict`). +My rule: + +> **Security invariants are locked and additive-only. Shape and surface are +> open.** A consumer may add behavior; they may never subtract a security +> property, and the type system should make subtraction impossible rather than +> merely discouraged. + +### 4.1 LOCKED (no extension point, enforced in `Build()`) + +- **The TenantAccess gate.** No token is issued for a (user, tenant) without an + active, unexpired `TenantAccess` row. Uniform for *all* users including + SysAdmin (carried forward from v1's locked decision #4). There is no hook to + bypass it. Sys access to a tenant goes through token exchange (§5), which + itself writes an audit record — there is no ambient path. +- **Reference (opaque) access tokens.** Self-contained JWT access tokens are + *not offered as an option*, because that would silently reintroduce the + revocation gap ADR-0001 exists to close. (ID tokens for OIDC clients remain + signed JWTs — that's protocol-correct and not a revocation concern.) +- **Refresh-token rotation with reuse detection.** On. +- **Support-token TTL ceiling.** A consumer may lower it; they cannot raise it + above the hard ceiling (e.g. 15 min, matching ADR-0001 §2.3). +- **Per-tenant token audience isolation** — a token minted for tenant A is + rejected at tenant B (the v1 `ValidateBearerTokenTenantMiddleware` invariant, + now enforced by OpenIddict audience validation rather than custom middleware). +- **Support requires a `reason` and emits an audit event.** Not optional. + +### 4.2 OPEN (documented extension points) + +- **Claims enrichment** (`IIdmtClaimsEnricher`) — add custom claims/scopes to a + token *after* the gate has run. Additive; cannot remove gate-mandated claims. +- **Tenant resolution strategy** — route/header/claim/basepath/custom resolver. +- **MFA factor selection** — which factors are required for whom (subject to the + locked rule that sys users *must* have a second factor). +- **Email transport** (`IIdmtEmailSender`) and link generation. +- **Additional authorization policies** layered on top of the built-ins. +- **Custom endpoints** under the pre-attached policy groups (§3.2). +- **Store backend** — EF is the default, but the repository ports allow another. + +### 4.3 Why this line + +The locked set is exactly the properties whose violation is invisible at runtime +until exploited — revocation latency, gate bypass, audience confusion, support +without audit. Those are *correctness*, not *configuration*. Everything in the +open set is a property the consumer can verify by inspection (an email arrives, a +claim appears, a route exists), so a misconfiguration there is self-revealing and +safe to delegate. The builder enforces the line structurally: locked behavior is +applied in `Build()` regardless of what the consumer called; open behavior is +opt-in via named methods. + +--- + +## 5. Token-exchange sys-support flow & reference-token revocation + +### 5.1 Sys-support via RFC 8693 token exchange + +This replaces ADR-0001's `/sys-switch` *and* v1's shadow-row-into-tenant +approach. Responsibilities split cleanly across contexts: + +``` +Sys user (already authenticated, holds reference token, SysRole=SysSupport) + │ POST /system/support/exchange + │ grant_type=urn:ietf:params:oauth:grant-type:token-exchange + │ subject_token= + │ audience=tenant:acme + │ scope=support + │ reason="ticket #1234" ← required (LOCKED, §4.1) + ▼ +Idmt.AspNetCore (System endpoints) — auth: RequireSysUser, rate-limited + ▼ +Idmt.OpenIddict / Support handler — OpenIddict token-exchange grant + │ 1) calls Core ISupportPolicy.CanSupport(subject, targetTenant) ┐ DOMAIN + │ 2) writes SupportAudit row (who, tenant, reason, expiry, ip) ┘ rule + audit + │ 3) mints REFERENCE token: aud=tenant:acme, scope=support, + │ ttl<=ceiling, claim idmt:support_of= + ▼ +Returns a tenant-scoped, opaque, short-lived support token. +``` + +- **Capability check lives in `Idmt.Core`** (`ISupportPolicy`) — unit-testable + with no OpenIddict. "Has an active SysRole grant" is a domain rule. +- **Mechanics live in `Idmt.OpenIddict`** — only it knows what a token-exchange + grant is. +- **The minted token is reference-typed and tenant-audienced**, so the existing + per-tenant audience isolation applies for free: a support token for `acme` + cannot touch `globex`. +- **Audit is written before the token is returned**, in the same unit of work as + the token-store insert, so there is no "token exists but no audit" window. +- The `SupportSession` policy (§3.3) lets tenant endpoints detect impersonation + and refuse destructive operations under support, or surface a banner. + +> **Distinctive choice #3:** I treat the support token as *just another +> tenant-audienced reference token with a `support` scope and a `support_of` +> claim*, not as a special session object. This means the entire revocation, +> expiry, and audience machinery is shared with normal tokens — one code path, +> one set of fitness functions. No second session table, no `IsSysSession` +> branch threaded through authorization (ADR-0001 had that branch in its core +> `CanAccessTenantAsync`; v2 deletes it). + +### 5.2 Reference-token revocation & blast radius + +- **Revoke one token:** delete its row in the OpenIddict token store. Instant; + next introspection fails. +- **Revoke a user everywhere (compromise / password change):** bulk-revoke by + subject in the token store. This is the OpenIddict-native replacement for + ADR-0001's "bump SecurityStamp → invalidate all ServerSessions." We still bump + `SecurityStamp` on the canonical user as the *source-of-truth signal*, and the + revocation is the *enforcement*. +- **Revoke a tenant grant:** revoke tokens whose `aud = tenant:X` for that + subject; the canonical user's tokens for *other* tenants survive. This is the + fine-grained-without-cross-tenant-collateral property ADR-0001 §2.7 wanted, + achieved by audience filtering rather than a session table. +- **Revoke a sys-support session:** revoke tokens with `scope=support` and + `support_of=`; the sys user's normal sys token survives. + +Blast radius is bounded by *audience + scope + subject* filters on a single +token store, which is conceptually identical to ADR-0001's session filters but +implemented by the engine we chose precisely so we don't maintain it. + +--- + +## 6. Key tradeoffs, risks, and the fitness functions that guard them + +| # | Risk | Likelihood / Impact | Guard (test / fitness function) | +|---|---|---|---| +| R1 | **Canonical-identity blast radius** — one stolen credential ⇒ all tenants. | Med / High | LOCKED: sys users require a second factor; multi-tenant members require MFA (config, defaulting on). Fitness fn: assert no token is issued to a multi-tenant subject without an MFA-satisfied claim. | +| R2 | **Coupling to OpenIddict** — vendor API churn / future relicensing. | Med / Med | Architecture test: only `Idmt.OpenIddict` may reference `OpenIddict.*`. The `IIdmtAuthorizationServer` port in Core means a future engine swap is one assembly. | +| R3 | **Finbuckle ↔ OpenIddict tenancy reconciliation** — two systems each have a notion of "current tenant"; they can disagree (token says `acme`, route resolves `globex`). | **High / High — the sharpest risk.** | Audience = tenant is the single source of truth at the resource. Fitness fn / route-mutation fuzzer (carried from ADR-0001 §4): authenticate for tenant A, mutate the route segment to every other tenant, assert 403. Plus: the ClaimsPipeline stamps `aud` from the *resolved* tenant at issuance, and the resource validates `aud == resolved-tenant` on every request. | +| R4 | **TenantAccess gate bypass** — a refactor lets a token issue without the gate. | Low / Critical | The gate is a mandatory step in the ClaimsPipeline registered in `Build()`. Test: parametric "issue token for user with no/expired TenantAccess ⇒ denied" across every grant type, including token-exchange. | +| R5 | **Reference-token store hot path** — every API call introspects. | Med / Med | Bench + cache (OpenIddict supports introspection caching; mirror ADR-0001's bounded TTL). Fitness fn: p99 introspection latency budget asserted in a load smoke test. | +| R6 | **Migration from v1** — v1 uses ASP.NET bearer-token handler + per-tenant cookies; v2 uses OpenIddict reference tokens. Token formats differ. | High / Med | Dual-run window: v2 mounts the OpenIddict server alongside; v1 tokens expire naturally; force re-auth at cutover (ADR-0001 already accepts forced password reset pre-prod). Migrator carries `IdmtUser`/`TenantAccess`/`SysRole` rows unchanged — schema is largely preserved (§7), lowering migration risk relative to ADR-0001's destructive reshape. | +| R7 | **Support-flow audit gap** — token minted but not audited. | Low / High | Audit insert and token-store insert in one transaction (§5.1). Test: simulate audit-write failure ⇒ assert no token returned. | +| R8 | **Opinionated defaults too rigid** — a real consumer needs something the locked set forbids. | Med / Low | Document each locked item with its rationale and the *supported* alternative (e.g. "need long-lived API tokens? issue a separate API-key surface, don't unlock self-contained access tokens"). Escape hatches are *parallel surfaces*, never weakened core. | + +> **Distinctive choice #4:** I elevate R3 (Finbuckle/OpenIddict tenancy +> reconciliation) to *the* primary risk and make the resource-side audience +> check, not the middleware, the enforcement point. v1 had a bespoke +> `ValidateBearerTokenTenantMiddleware`; v2 deletes it because audience +> validation is a first-class token property the engine enforces. The +> route-mutation fuzzer is promoted from "nice test" to a required CI fitness +> function gating merge. + +--- + +## 7. What v2 deletes, keeps, and adds — framed as boundary decisions + +### Deletes (because the boundary moved to OpenIddict) +- **`ValidateBearerTokenTenantMiddleware`** → replaced by token `aud` validation. +- **`ITokenRevocationService` + `TokenRevocationCleanupService`** → replaced by + the OpenIddict token store + its built-in pruning. We owned a revocation list + because the bearer handler had none; OpenIddict reference tokens make the + store authoritative. +- **The hybrid `CookieOrBearer` PolicyScheme + per-tenant cookie isolation as + the primary auth model** → the API auth model becomes OpenIddict reference + tokens. Cookies, if kept at all, become a thin first-party-client convenience + over the same token store, not a parallel auth universe. (This collapses a + whole class of "cookie path vs bearer path diverge" bugs.) +- **The bespoke bearer expiry/refresh config** (`BearerOptions`) → OpenIddict + token + refresh lifetimes. +- **ADR-0001's `ServerSession` table, `/sys-switch`, and `IsSysSession` + branch** → reference tokens + token exchange. We get the *capability* without + building or maintaining the *mechanism*. +- **The five-positional-delegate `AddIdmt`** → the builder (§3.1). + +### Keeps (because the boundary is genuinely ours) +- **Canonical `IdmtUser : IdentityUser`, globally unique email.** ASP.NET + Identity stays as the user/credential store. OpenIddict sits *in front of* it. +- **`TenantAccess` (IsActive, ExpiresAt) and the uniform login-time gate** — now + *also* enforced at token issuance. This is the heart of what IDMT is. +- **`SysRole` (None/SysAdmin/SysSupport)** as a global flag. +- **`IdmtRole` per-tenant**, projected into per-tenant token claims. +- **Finbuckle.MultiTenant** for tenant resolution (isolated in `Idmt.MultiTenancy`). +- **Two EF contexts** (app data + tenant store), now joined by OpenIddict's own + entity sets in the persistence assembly. +- **Vertical-slice endpoint shape** — but demoted to a *delivery* convention + inside `Idmt.AspNetCore`, not a layering primitive (see §1, distinctive #1). +- **ErrorOr + FluentValidation, PiiMasker, the error catalog.** + +### Adds +- The four-package boundary with the dependency-rule firewall (§2). +- `Idmt.Architecture.Tests` enforcing assembly reference rules as CI gates. +- `TenantMember` and `SupportSession` policies (§3.3). +- The token-exchange support surface and `SupportAudit` (§5.1). +- The `IdmtBuilder` opinionated/open seam (§4). + +--- + +## 8. One-paragraph summary of the bet + +v2 stops competing with OpenIddict on commodity IdP machinery and instead +becomes the **multi-tenant authorization layer and endpoint scaffolding** that +sits on top of it. The decomposition is firewalled by assembly boundaries so +that OpenIddict, Finbuckle, and EF are each named in exactly one package and are +swappable behind Core-owned ports; the domain (`Idmt.Core`) can be unit-tested +with zero infrastructure. Sys-support becomes a tenant-audienced, reason-bearing, +audited reference token minted via RFC 8693 token exchange — sharing one +revocation/expiry/audience code path with every other token, deleting ADR-0001's +bespoke session table and `IsSysSession` branch. The opinionated/customizable +line is drawn structurally: security invariants (gate, reference tokens, refresh +rotation, support TTL, audience isolation, audited support) are locked and +applied unconditionally in the builder; shape, claims, MFA factors, transport, +and extra endpoints are open. The Finbuckle/OpenIddict tenancy reconciliation is +named the primary risk and is guarded by making the token audience the single +source of truth plus a CI-gating route-mutation fuzzer. diff --git a/adr/0002-v2-sketch-code-architect.md b/adr/0002-v2-sketch-code-architect.md new file mode 100644 index 0000000..e47fe58 --- /dev/null +++ b/adr/0002-v2-sketch-code-architect.md @@ -0,0 +1,1126 @@ +# ADR 0002 — IDMT v2 Architecture Sketch: OpenIddict-Based Multi-Tenant Authorization Layer + +- **Status:** Draft / Design Sketch +- **Date:** 2026-06-04 +- **Author:** @idotta (architecture sketch) +- **Scope:** Greenfield v2 rewrite target architecture — design artifact only, no implementation +- **Supersedes:** v1 hand-rolled bearer token machinery (BearerToken middleware, TokenRevocationService, RefreshToken slice, Login.TokenLoginHandler, ValidateBearerTokenTenantMiddleware) + +--- + +## 0. Purpose and Scope + +This document sketches the concrete project layout, public API, slice shapes, and migration map for a v2 rewrite of IDMT. It is grounded in the v1 source code read directly from `Idmt.Plugin/` and `tests/`. The target architecture replaces hand-rolled OAuth2 token machinery with **OpenIddict** while keeping every concept that is genuinely IDMT's: canonical `IdmtUser`, `TenantAccess`, `SysRole`, Finbuckle tenant resolution, vertical-slice features, ErrorOr, FluentValidation, and the `AddIdmt()` / `UseIdmt()` entry-point pattern. + +Three architects are producing parallel sketches; this sketch is intended for side-by-side comparison. Section numbers match the requested output format. + +--- + +## 1. Migration Map: v1 → v2 + +Every v1 file is mapped to one of three fates: **DELETED** (OpenIddict owns the concern), **KEPT** (unchanged or trivially adapted), **RESHAPED** (substantial rewrite into a different abstraction). + +### 1.1 Auth Features (`Idmt.Plugin/Features/Auth/`) + +| v1 File | Fate | Reason / v2 equivalent | +|---|---|---| +| `Login.cs` — `LoginHandler` (cookie sign-in) | **RESHAPED** → `Features/Auth/Authorize.cs` | Becomes the interactive OIDC `/connect/authorize` handler; session-cookie issuance delegates to OpenIddict's `SignInManager`-integrated flow. Logic: resolve tenant, validate `TenantAccess`, then `SignInAsync` against the OpenIddict Authorization endpoint. | +| `Login.cs` — `TokenLoginHandler` (bearer token) | **DELETED** — OpenIddict owns it | `/connect/token` with `password` grant or `authorization_code`. The hand-rolled `BearerTokenProtector.Protect(authTicket)` pattern is replaced entirely. | +| `Login.cs` — `AccessTokenResponse` record | **DELETED** | OpenIddict returns RFC 6749 JSON (`access_token`, `token_type`, `expires_in`, `refresh_token`). | +| `RefreshToken.cs` | **DELETED** | OpenIddict `/connect/token` with `refresh_token` grant owns refresh rotation. `RefreshTokenHandler`, `RefreshTokenProtector`, revocation check in handler — all gone. | +| `Logout.cs` | **RESHAPED** → `Features/Auth/Revoke.cs` | Wraps OpenIddict `/connect/revocation` (RFC 7009). Cookie sign-out still calls `SignInManager.SignOutAsync`. `ITokenRevocationService.RevokeUserTokensAsync` is replaced by OpenIddict's built-in revocation. | +| `ConfirmEmail.cs` | **KEPT** | ASP.NET Core Identity token, not an OAuth2 concern. Minor reshape: endpoint names and Base64URL decode stay identical. | +| `ConfirmEmailChange.cs` | **KEPT** | Same — Identity-issued `ChangeEmail` token. `PendingEmail` staging flow unchanged. | +| `ResendConfirmationEmail.cs` | **KEPT** | Pure Identity concern; no token infrastructure changes. | +| `ForgotPassword.cs` | **KEPT** | Pure Identity concern. | +| `ResetPassword.cs` | **KEPT** | Pure Identity concern. | +| `DiscoverTenants.cs` | **KEPT** | Pre-login tenant discovery via `TenantAccess` join. No token machinery involved. | + +### 1.2 Manage Features (`Idmt.Plugin/Features/Manage/`) + +| v1 File | Fate | v2 notes | +|---|---|---| +| `RegisterUser.cs` | **KEPT** | Minor update: token grant for email link uses OpenIddict's link generator convention rather than `userManager.GeneratePasswordResetTokenAsync` sent directly. Core logic (transaction, TenantAccess row creation) unchanged. | +| `UnregisterUser.cs` | **KEPT** | Hard-delete path unchanged. Add: call OpenIddict token store to revoke all tokens for the deleted user's `sub` claim. | +| `UpdateUser.cs` | **KEPT** | `IsActive` flag toggle unchanged. | +| `GetUserInfo.cs` | **KEPT** | `GetRolesAsync` + `SysRole` claim read unchanged. | +| `UpdateUserInfo.cs` | **KEPT** | OOB email change flow (`PendingEmail`, `GenerateChangeEmailTokenAsync`) unchanged. Password change: after `ChangePasswordAsync`, call OpenIddict token store to revoke existing tokens for user (replaces `tokenRevocationService.RevokeUserTokensAsync`). | + +### 1.3 Admin Features (`Idmt.Plugin/Features/Admin/`) + +| v1 File | Fate | v2 notes | +|---|---|---| +| `CreateTenant.cs` | **KEPT** | Role seeding + invoker `TenantAccess` bootstrap in `BootstrapTenantAsync` unchanged. | +| `DeleteTenant.cs` | **KEPT** | Soft-delete plus: revoke all OpenIddict tokens whose `tenant` claim equals the deleted tenant identifier. | +| `GrantTenantAccess.cs` | **KEPT** | `TenantAccess` row write unchanged. | +| `RevokeTenantAccess.cs` | **RESHAPED** | `TenantAccess.IsActive = false` stays. Replace `tokenRevocationService.RevokeUserTokensAsync(userId, tenantId)` with an OpenIddict-native call: enumerate and revoke all `OpenIddictToken` rows where `Subject == userId && [tenant claim] == tenantId`. | +| `GetAllTenants.cs` | **KEPT** | Paginated query unchanged. | +| `GetUserTenants.cs` | **KEPT** | `TenantAccess` join unchanged. | +| `AdminModels.cs` (`TenantInfoResponse`, `PaginatedResponse`) | **KEPT** | Shared response records unchanged. | +| NEW: `SupportTenant.cs` | **NEW** | Token-exchange "sys-support a tenant" slice (RFC 8693). SysSupport/SysAdmin trades their token for a tenant-scoped, time-bound, audited support token via OpenIddict token exchange. Replaces the old shadow-row approach. See Section 5. | + +### 1.4 Services + +| v1 File | Fate | v2 notes | +|---|---|---| +| `ICurrentUserService.cs` / `CurrentUserService.cs` | **KEPT** | `TenantId`, `TenantIdentifier` now read from OpenIddict's JWT standard `tenant` claim rather than the Finbuckle-strategy claim. Interface unchanged. | +| `ITenantAccessService.cs` / `TenantAccessService.cs` | **KEPT** | `CanAccessTenantAsync`, `CanAssignRole`, `CanManageUser` — all unchanged. | +| `ITenantOperationService.cs` / `TenantOperationService.cs` | **KEPT** | Inner-scope DI pattern unchanged. | +| `ITokenRevocationService.cs` / `TokenRevocationService.cs` | **DELETED** | OpenIddict's `IOpenIddictTokenManager` is the revocation store. `RevokedToken` EF entity deleted. `TokenRevocationCleanupService` deleted (OpenIddict has its own pruning via `OpenIddictEntityFrameworkCoreCleanupService` / `IOpenIddictTokenManager.PruneAsync`). | +| `IdmtUserClaimsPrincipalFactory.cs` | **RESHAPED** | Still extends `UserClaimsPrincipalFactory`. Now also emits the OIDC `sub` claim (= `user.Id`), `iss`, and the tenant claim as `tenant` (standard claim name, not Finbuckle strategy-driven). The factory is the single claim emission point consumed by OpenIddict's authorization endpoint. | +| `IdmtLinkGenerator.cs` / `IIdmtLinkGenerator.cs` | **KEPT** | Link generation for email confirmation / password reset is unchanged. | +| `IdmtEmailSender.cs` / `IdmtEmailSenderStartupCheck.cs` | **KEPT** | Email contract unchanged. | +| `PiiMasker.cs` | **KEPT** | Structured logging utility unchanged. | +| `Base64Service.cs` | **KEPT** | Token decode utility unchanged. | +| `TenantOperationService.cs` | **KEPT** | ExecuteInTenantScopeAsync pattern unchanged. | + +### 1.5 Middleware + +| v1 File | Fate | v2 notes | +|---|---|---| +| `ValidateBearerTokenTenantMiddleware.cs` | **DELETED** | Replaced by an OpenIddict **validation handler** (a typed `IOpenIddictValidationHandler`) that enforces `token.Claims["tenant"] == currentTenant.Identifier`. The middleware approach is replaced by the OpenIddict pipeline hook; the logic (fail-closed, 401/403 ProblemDetails) stays identical but sits inside `Features/Auth/TenantValidationHandler.cs`. | +| `CurrentUserMiddleware.cs` | **KEPT** | Populates `ICurrentUserService` from `HttpContext.User` after OpenIddict validation runs. | + +### 1.6 Models + +| v1 File | Fate | v2 notes | +|---|---|---| +| `IdmtUser.cs` | **KEPT** | `SysRole`, `PendingEmail`, `IsActive`, `LastLoginAt`, `Guid.CreateVersion7()` unchanged. | +| `IdmtRole.cs` | **KEPT** | Per-tenant `IdmtRole`, `IdmtDefaultRoleTypes` unchanged. | +| `SysRoleKind.cs` | **KEPT** | Enum unchanged. | +| `TenantAccess.cs` | **KEPT** | `IsActive`, `ExpiresAt` unchanged. | +| `IdmtTenantInfo.cs` | **KEPT** | `IsActive`, `LoginPath`, `LogoutPath` unchanged. | +| `IdmtAuditLog.cs` | **KEPT** | Audit interceptor unchanged. | +| `IAuditable.cs` | **KEPT** | Interface unchanged. | +| `RevokedToken.cs` | **DELETED** | OpenIddict token store replaces this. | + +### 1.7 Persistence + +| v1 File | Fate | v2 notes | +|---|---|---| +| `IdmtDbContext.cs` | **RESHAPED** | Inherits `OpenIddictDbContext<...>` (or uses `UseOpenIddict()` extension on the existing `MultiTenantIdentityDbContext` base) to add the four OpenIddict entity sets. `RevokedTokens` DbSet removed. `DateTimeOffset` converter and audit interceptor stay. | +| `IdmtTenantStoreDbContext.cs` | **KEPT** | Finbuckle EFCore store unchanged. | + +### 1.8 Configuration + +| v1 File | Fate | v2 notes | +|---|---|---| +| `IdmtOptions.cs` — `BearerOptions` class | **RESHAPED** | Replaces `BearerTokenExpiration` / `RefreshTokenExpiration` with OpenIddict equivalents (`AccessTokenLifetime`, `RefreshTokenLifetime`). The `QueryTokenPrefix` SignalR hook moves to an OpenIddict validation event. | +| `IdmtOptions.cs` — everything else | **KEPT** | `ApplicationOptions`, `MultiTenantOptions`, `DatabaseOptions`, `RateLimitingOptions`, `IdmtPasswordOptions`, `IdmtAuthOptions` policy constants unchanged. | +| `IdmtAuthOptions` constants | **KEPT** | `CookieOrBearerScheme`, `RequireSysAdminPolicy`, `RequireSysUserPolicy`, `RequireTenantManagerPolicy`, `CookieOnlyPolicy`, `BearerOnlyPolicy` all unchanged. Authorization policies are reconstructed with OpenIddict's Bearer scheme. | +| `IdmtOptionsValidator.cs` | **KEPT** | Validation rules unchanged; drop validation of bearer token expiry config when `BearerOptions` is refactored. | +| `IdmtEndpointNames.cs` | **KEPT** + extended | Add `ConnectAuthorize`, `ConnectToken`, `ConnectRevoke`, `ConnectUserInfo`. | + +### 1.9 Migration Harness + +| v1 File | Fate | +|---|---| +| `Migration/CanonicalIdentityDataMigrator.cs` | **KEPT** — v1→v2 canonical migrator reusable as v1.x→v2 pre-flight step. | +| `Migration/MigrationServiceCollectionExtensions.cs` | **KEPT** | +| `Migration/MigrationCurrentUserService.cs` | **KEPT** | + +--- + +## 2. Solution & Project Layout + +``` +Idmt.slnx +│ +├── src/ +│ ├── Idmt.Plugin/ # Main NuGet package (Idmt.Plugin) +│ │ ├── Configuration/ +│ │ │ ├── IdmtOptions.cs # KEPT + reshaped BearerOptions +│ │ │ ├── IdmtOptionsValidator.cs # KEPT +│ │ │ └── IdmtEndpointNames.cs # KEPT + extended +│ │ │ +│ │ ├── Constants/ +│ │ │ ├── IdmtClaimTypes.cs # KEPT + add "tenant", "sub" +│ │ │ └── AuditAction.cs # KEPT +│ │ │ +│ │ ├── Errors/ +│ │ │ └── IdmtErrors.cs # KEPT + add Token.ExchangeFailed +│ │ │ +│ │ ├── Extensions/ +│ │ │ ├── ServiceCollectionExtensions.cs # RESHAPED — add OpenIddict wiring +│ │ │ └── ApplicationBuilderExtensions.cs # RESHAPED — add OpenIddict endpoints +│ │ │ +│ │ ├── Features/ +│ │ │ ├── AuthEndpoints.cs # RESHAPED — remove /login/token, /refresh; add OIDC endpoint registrations +│ │ │ ├── ManageEndpoints.cs # KEPT +│ │ │ ├── AdminEndpoints.cs # KEPT + add SupportTenant +│ │ │ │ +│ │ │ ├── Auth/ +│ │ │ │ ├── Authorize.cs # NEW — interactive OIDC authorize flow (wraps /connect/authorize) +│ │ │ │ ├── ConfirmEmail.cs # KEPT +│ │ │ │ ├── ConfirmEmailChange.cs # KEPT +│ │ │ │ ├── DiscoverTenants.cs # KEPT +│ │ │ │ ├── ForgotPassword.cs # KEPT +│ │ │ │ ├── ResendConfirmationEmail.cs # KEPT +│ │ │ │ ├── ResetPassword.cs # KEPT +│ │ │ │ ├── Revoke.cs # NEW (replaces Logout.cs token revocation) +│ │ │ │ ├── TenantValidationHandler.cs # NEW (replaces ValidateBearerTokenTenantMiddleware) +│ │ │ │ └── UserInfo.cs # NEW — /connect/userinfo slice +│ │ │ │ +│ │ │ ├── Admin/ +│ │ │ │ ├── AdminModels.cs # KEPT +│ │ │ │ ├── CreateTenant.cs # KEPT +│ │ │ │ ├── DeleteTenant.cs # KEPT +│ │ │ │ ├── GetAllTenants.cs # KEPT +│ │ │ │ ├── GetUserTenants.cs # KEPT +│ │ │ │ ├── GrantTenantAccess.cs # KEPT +│ │ │ │ ├── RevokeTenantAccess.cs # RESHAPED +│ │ │ │ └── SupportTenant.cs # NEW — token-exchange slice (RFC 8693) +│ │ │ │ +│ │ │ ├── Manage/ +│ │ │ │ ├── GetUserInfo.cs # KEPT +│ │ │ │ ├── RegisterUser.cs # KEPT +│ │ │ │ ├── UnregisterUser.cs # KEPT +│ │ │ │ ├── UpdateUser.cs # KEPT +│ │ │ │ └── UpdateUserInfo.cs # KEPT +│ │ │ │ +│ │ │ └── Health/ +│ │ │ └── BasicHealthCheck.cs # KEPT +│ │ │ +│ │ ├── Middleware/ +│ │ │ └── CurrentUserMiddleware.cs # KEPT (ValidateBearerTokenTenantMiddleware DELETED) +│ │ │ +│ │ ├── Migration/ +│ │ │ ├── CanonicalIdentityDataMigrator.cs # KEPT +│ │ │ ├── MigrationCurrentUserService.cs # KEPT +│ │ │ └── MigrationServiceCollectionExtensions.cs # KEPT +│ │ │ +│ │ ├── Models/ +│ │ │ ├── IAuditable.cs # KEPT +│ │ │ ├── IdmtAuditLog.cs # KEPT +│ │ │ ├── IdmtRole.cs # KEPT +│ │ │ ├── IdmtTenantInfo.cs # KEPT +│ │ │ ├── IdmtUser.cs # KEPT +│ │ │ ├── SysRoleKind.cs # KEPT +│ │ │ └── TenantAccess.cs # KEPT +│ │ │ # RevokedToken.cs DELETED +│ │ │ +│ │ ├── Persistence/ +│ │ │ ├── IdmtDbContext.cs # RESHAPED — add OpenIddict entity sets, remove RevokedTokens +│ │ │ └── IdmtTenantStoreDbContext.cs # KEPT +│ │ │ +│ │ ├── Services/ +│ │ │ ├── Base64Service.cs # KEPT +│ │ │ ├── CurrentUserService.cs # KEPT +│ │ │ ├── ICurrentUserService.cs # KEPT +│ │ │ ├── IdmtEmailSender.cs # KEPT +│ │ │ ├── IdmtEmailSenderStartupCheck.cs # KEPT +│ │ │ ├── IdmtLinkGenerator.cs # KEPT +│ │ │ ├── IdmtUserClaimsPrincipalFactory.cs # RESHAPED +│ │ │ ├── ITenantAccessService.cs # KEPT +│ │ │ ├── ITenantOperationService.cs # KEPT +│ │ │ ├── PiiMasker.cs # KEPT +│ │ │ ├── TenantAccessService.cs # KEPT +│ │ │ └── TenantOperationService.cs # KEPT +│ │ │ # ITokenRevocationService.cs DELETED +│ │ │ # TokenRevocationService.cs DELETED +│ │ │ # TokenRevocationCleanupService.cs DELETED +│ │ │ +│ │ └── Validation/ +│ │ ├── ValidationHelper.cs # KEPT +│ │ ├── Validators.cs # KEPT +│ │ └── [feature validators] # KEPT +│ │ +│ └── Idmt.Plugin.Abstractions/ # OPTIONAL second package +│ # (future: thin interfaces-only package for consumers +│ # who want compile-time types without pulling full plugin) +│ # Not required for v2.0. +│ +├── samples/ +│ └── Idmt.BasicSample/ # Updated sample using OpenIddict PKCE flow +│ +└── tests/ + ├── Idmt.UnitTests/ # KEPT structure — see Section 6 + └── Idmt.BasicSample.Tests/ # KEPT structure — see Section 6 +``` + +**NuGet package boundaries:** v2 ships as a single `Idmt.Plugin` package. NuGet dependencies gain: +- `OpenIddict.AspNetCore` (token server + validation) +- `OpenIddict.EntityFrameworkCore` (token / application / authorization / scope stores) + +Finbuckle, ASP.NET Core Identity, ErrorOr, FluentValidation, EntityFrameworkCore — all unchanged. + +--- + +## 3. Public API Sketch + +### 3.1 `AddIdmt()` Entry Point + +The signature is deliberately kept identical to v1 to minimize consumer migration surface. + +```csharp +// Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs + +public delegate void CustomizeAuthentication(AuthenticationBuilder authenticationBuilder); +public delegate void CustomizeAuthorization(AuthorizationBuilder authorizationBuilder); +// NEW in v2: +public delegate void CustomizeOpenIddict(OpenIddictBuilder openIddictBuilder); + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddIdmt( + this IServiceCollection services, + IConfiguration configuration, + Action? configureDb = null, + Action? configureOptions = null, + CustomizeAuthentication? customizeAuthentication = null, + CustomizeAuthorization? customizeAuthorization = null, + CustomizeOpenIddict? customizeOpenIddict = null) // NEW parameter + where TDbContext : IdmtDbContext + { ... } + + // Overload without TDbContext (default IdmtDbContext) — unchanged shape + public static IServiceCollection AddIdmt( + this IServiceCollection services, + IConfiguration configuration, + Action? configureDb = null, + Action? configureOptions = null, + CustomizeAuthentication? customizeAuthentication = null, + CustomizeAuthorization? customizeAuthorization = null, + CustomizeOpenIddict? customizeOpenIddict = null) + { ... } +} +``` + +Internal registration sequence (mirrors v1's numbered steps, new step 8a inserted): + +``` +1. ConfigureIdmtOptions // unchanged +2. ConfigureDatabase // + adds OpenIddict EF stores to DbContext +3. ConfigureIdentity // unchanged +4. ConfigureAuthentication // PolicyScheme: CookieOrBearer now forwards + // bearer to OpenIddict validation scheme +5. ConfigureAuthorization // policies unchanged +6. ConfigureMultiTenant // unchanged +7. ConfigureOpenIddict // NEW — registers AS + validation pipelines +8. RegisterApplicationServices // ITokenRevocationService removed +9. RegisterFeatures // remove Login.ITokenLoginHandler etc. +10. RegisterMiddleware // remove ValidateBearerTokenTenantMiddleware +11. ConfigureRateLimiting // unchanged +``` + +### 3.2 `ConfigureOpenIddict` (new internal method) + +```csharp +private static void ConfigureOpenIddict( + IServiceCollection services, + IdmtOptions idmtOptions, + Action? configureDb, + CustomizeOpenIddict? customizeOpenIddict) +{ + var openIddictBuilder = services.AddOpenIddict() + + // Authorization server component + .AddServer(options => + { + // OAuth2 / OIDC endpoints + options.SetAuthorizationEndpointUris("/connect/authorize") + .SetTokenEndpointUris("/connect/token") + .SetRevocationEndpointUris("/connect/revocation") + .SetUserinfoEndpointUris("/connect/userinfo") + .SetIntrospectionEndpointUris("/connect/introspect"); + + // v2 locked decision: reference (opaque) access tokens for instant revocation. + // Token payloads are stored server-side; clients receive an opaque handle. + options.UseReferenceAccessTokens() + .UseReferenceRefreshTokens(); + + // Supported grants + options.AllowAuthorizationCodeFlow() + .AllowRefreshTokenFlow() + .AllowTokenExchangeFlow(); // RFC 8693 — for SupportTenant slice + + // Lifetimes mirror v1 IdmtOptions.Identity.Bearer defaults + options.SetAccessTokenLifetime(idmtOptions.Identity.Bearer.BearerTokenExpiration) + .SetRefreshTokenLifetime(idmtOptions.Identity.Bearer.RefreshTokenExpiration); + + // Sign / encrypt with development keys in dev; consumer provides production keys + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + // ASP.NET Core integration + options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough() + .EnableRevocationEndpointPassthrough() + .EnableUserinfoEndpointPassthrough(); + }) + + // Validation component — validates reference tokens server-side + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + + // Tenant isolation enforcement hook + options.AddEventHandler( + builder => builder + .UseSingletonHandler() + .SetOrder(ValidateIdentityModelToken.Descriptor.Order + 500)); + }) + + // EF Core token / application / authorization / scope stores + .AddCore(options => + { + options.UseEntityFrameworkCore() + .UseDbContext(); + }); + + customizeOpenIddict?.Invoke(openIddictBuilder); +} +``` + +### 3.3 Updated `IdmtOptions` Shape + +Only the `BearerOptions` class changes. All other option classes are byte-for-byte compatible with v1. + +```csharp +// v2 BearerOptions — replaces v1 BearerOptions +// v1 BearerTokenExpiration → AccessTokenLifetime (same default: 60 min) +// v1 RefreshTokenExpiration → RefreshTokenLifetime (same default: 30 days) +public class BearerOptions +{ + public const string HeaderTokenPrefix = "Bearer"; + public const string QueryTokenPrefix = "access_token"; // SignalR/WebSocket — kept + + // Renamed from BearerTokenExpiration (value and default unchanged) + public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(60); + + // Renamed from RefreshTokenExpiration (value and default unchanged) + public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30); +} +``` + +### 3.4 Authentication Scheme Wiring + +```csharp +// PolicyScheme — v2 forwards to OpenIddict validation scheme instead of +// IdentityConstants.BearerScheme. Cookie scheme unchanged. +authenticationBuilder.AddPolicyScheme(IdmtAuthOptions.CookieOrBearerScheme, "Cookie or Bearer", + options => + { + options.ForwardDefaultSelector = context => + { + var authHeader = context.Request.Headers.Authorization.ToString(); + if (!string.IsNullOrEmpty(authHeader) && + authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + } + return IdentityConstants.ApplicationScheme; + }; + }); +``` + +### 3.5 `UseIdmt()` / `MapIdmtEndpoints()` — Unchanged Shape + +```csharp +// ApplicationBuilderExtensions.cs — consumer call site unchanged +app.UseIdmt(); +app.MapIdmtEndpoints(); + +// Internally, MapIdmtEndpoints also maps OIDC endpoints: +endpoints.MapGroup(apiPrefix).MapAuthEndpoints(); +// AuthEndpoints.cs adds: +// auth.MapAuthorizeEndpoint(); → POST/GET /connect/authorize (passthrough) +// auth.MapTokenEndpoint(); → POST /connect/token (passthrough) +// auth.MapRevocationEndpoint(); → POST /connect/revocation +// auth.MapUserInfoEndpoint(); → GET /connect/userinfo +// [existing email/password/discover endpoints unchanged] +``` + +### 3.6 Authorization Policy Constants — Unchanged + +```csharp +// IdmtAuthOptions constants — 100% backward compatible +public const string CookieOrBearerScheme = "CookieOrBearer"; +public const string CookieOnlyPolicy = "CookieOnly"; +public const string BearerOnlyPolicy = "BearerOnly"; +public const string RequireSysAdminPolicy = "RequireSysAdmin"; +public const string RequireSysUserPolicy = "RequireSysUser"; +public const string RequireTenantManagerPolicy = "RequireTenantManager"; +``` + +Policies are rebuilt in `ConfigureAuthorization` using `OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme` as the bearer scheme in place of `IdentityConstants.BearerScheme`. + +--- + +## 4. Representative Vertical Slice: `SupportTenant.cs` (Token Exchange) + +This is a new v2 Admin slice. It demonstrates how the v1 slice pattern is preserved exactly, with OpenIddict plumbing substituted for hand-rolled token work. + +```csharp +// Idmt.Plugin/Features/Admin/SupportTenant.cs + +using ErrorOr; +using FluentValidation; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; +using Idmt.Plugin.Models; +using Idmt.Plugin.Services; +using Idmt.Plugin.Validation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using System.Security.Claims; + +namespace Idmt.Plugin.Features.Admin; + +public static class SupportTenant +{ + // --- Request / Response --- + + public sealed record SupportTenantRequest + { + /// + /// The identifier of the tenant the sys-user wants to act as support in. + /// + public required string TenantIdentifier { get; init; } + + /// + /// How long the support token should be valid. Capped by IdmtOptions. + /// + public TimeSpan? RequestedLifetime { get; init; } + } + + public sealed record SupportTenantResponse + { + /// Opaque reference access token scoped to the target tenant. + public required string AccessToken { get; init; } + public required long ExpiresIn { get; init; } + public required string TokenType { get; init; } = "Bearer"; + public required string TenantIdentifier { get; init; } + } + + // --- Handler interface + implementation --- + + public interface ISupportTenantHandler + { + Task> HandleAsync( + SupportTenantRequest request, + ClaimsPrincipal invoker, + CancellationToken cancellationToken = default); + } + + internal sealed class SupportTenantHandler( + IOpenIddictTokenManager tokenManager, + IOpenIddictApplicationManager applicationManager, + ITenantAccessService tenantAccessService, + ICurrentUserService currentUserService, + IMultiTenantStore tenantStore, + IdmtDbContext dbContext, + IOptions idmtOptions, + TimeProvider timeProvider, + ILogger logger) : ISupportTenantHandler + { + // Max lifetime for a support token — hard cap regardless of what caller requests. + private static readonly TimeSpan MaxSupportTokenLifetime = TimeSpan.FromHours(4); + + public async Task> HandleAsync( + SupportTenantRequest request, + ClaimsPrincipal invoker, + CancellationToken cancellationToken = default) + { + var invokerUserId = currentUserService.UserId; + if (invokerUserId is null) + return IdmtErrors.Auth.Unauthorized; + + // 1. Validate target tenant exists and is active. + var targetTenant = await tenantStore.GetByIdentifierAsync(request.TenantIdentifier); + if (targetTenant is null) + return IdmtErrors.Tenant.NotFound; + if (!targetTenant.IsActive) + return IdmtErrors.Tenant.Inactive; + + // 2. Uniform TenantAccess gate — even SysAdmin must have an active row + // (locked decision #4 carries forward to v2). + if (!await tenantAccessService.CanAccessTenantAsync( + invokerUserId.Value, targetTenant.Id!, cancellationToken)) + return IdmtErrors.Auth.Unauthorized; + + // 3. Build the support principal — tenant claim scoped to target tenant, + // SysRole forwarded, audit metadata embedded. + var lifetime = request.RequestedLifetime.HasValue + ? TimeSpan.FromTicks(Math.Min(request.RequestedLifetime.Value.Ticks, MaxSupportTokenLifetime.Ticks)) + : TimeSpan.FromHours(1); + + var now = timeProvider.GetUtcNow(); + + var identity = new ClaimsIdentity( + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + OpenIddictConstants.Claims.Name, + OpenIddictConstants.Claims.Role); + + // sub = canonical invoker userId (not re-created; audit trail unbroken) + identity.AddClaim(OpenIddictConstants.Claims.Subject, invokerUserId.Value.ToString()); + identity.AddClaim("tenant", targetTenant.Identifier!); + identity.AddClaim("tenant_id", targetTenant.Id!); + identity.AddClaim("support_session", "true"); + identity.AddClaim("support_invoker", invokerUserId.Value.ToString()); + + // Forward SysRole so RequireSysUser / RequireSysAdmin policies pass in target tenant + var sysRole = invoker.FindFirstValue(ClaimTypes.Role); + if (!string.IsNullOrEmpty(sysRole)) + identity.AddClaim(ClaimTypes.Role, sysRole); + + // Destination: access token only (refresh not issued for support sessions) + foreach (var claim in identity.Claims) + { + claim.SetDestinations(OpenIddictConstants.Destinations.AccessToken); + } + + var principal = new ClaimsPrincipal(identity); + principal.SetAccessTokenLifetime(lifetime); + principal.SetScopes(OpenIddictConstants.Scopes.OpenId, "idmt"); + + // 4. Write an audit log entry before token creation. + dbContext.AuditLogs.Add(new IdmtAuditLog + { + UserId = invokerUserId, + TenantId = targetTenant.Id, + Action = AuditAction.SupportSessionStarted.ToString(), + Resource = nameof(TenantAccess), + ResourceId = $"{invokerUserId}:{targetTenant.Id}", + Success = true, + Timestamp = now, + IpAddress = currentUserService.IpAddress, + UserAgent = currentUserService.UserAgent, + }); + await dbContext.SaveChangesAsync(cancellationToken); + + // 5. Create the reference access token via OpenIddict token manager. + // The token descriptor follows the OpenIddict server-side store contract. + var descriptor = new OpenIddictTokenDescriptor + { + Principal = principal, + Status = OpenIddictConstants.Statuses.Valid, + Subject = invokerUserId.Value.ToString(), + Type = OpenIddictConstants.TokenTypes.Bearer, + ExpirationDate = now.Add(lifetime), + CreationDate = now, + }; + + var token = await tokenManager.CreateAsync(descriptor, cancellationToken); + // OpenIddict reference tokens: the opaque handle is the ReferenceId. + var opaqueToken = await tokenManager.GetReferenceIdAsync(token!, cancellationToken) + ?? throw new InvalidOperationException("OpenIddict did not produce a reference token handle."); + + return new SupportTenantResponse + { + AccessToken = opaqueToken, + ExpiresIn = (long)lifetime.TotalSeconds, + TokenType = "Bearer", + TenantIdentifier = targetTenant.Identifier!, + }; + } + } + + // --- Validator (inline, consistent with v1 style) --- + + internal sealed class SupportTenantRequestValidator : AbstractValidator + { + public SupportTenantRequestValidator() + { + RuleFor(x => x.TenantIdentifier) + .NotEmpty() + .Must(Validators.IsValidTenantIdentifier) + .WithMessage("Tenant identifier must be lowercase alphanumeric, dashes, or underscores."); + + RuleFor(x => x.RequestedLifetime) + .Must(lt => lt == null || lt > TimeSpan.Zero) + .WithMessage("Requested lifetime must be positive."); + } + } + + // --- Endpoint mapper (static extension method, identical v1 pattern) --- + + public static RouteHandlerBuilder MapSupportTenantEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost( + "/tenants/{tenantIdentifier}/support-session", + async Task, UnauthorizedHttpResult, NotFound, ForbidHttpResult, ValidationProblem, ProblemHttpResult>> ( + string tenantIdentifier, + [FromBody] SupportTenantRequest request, + ClaimsPrincipal invoker, + [FromServices] ISupportTenantHandler handler, + [FromServices] IValidator validator, + HttpContext context) => + { + // Bind route into request for unified validation + var merged = request with { TenantIdentifier = tenantIdentifier }; + + if (ValidationHelper.Validate(merged, validator) is { } validationErrors) + return TypedResults.ValidationProblem(validationErrors); + + var result = await handler.HandleAsync(merged, invoker, + cancellationToken: context.RequestAborted); + + if (result.IsError) + { + return result.FirstError.Type switch + { + ErrorType.Unauthorized => TypedResults.Unauthorized(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Validation => TypedResults.Problem( + result.FirstError.Description, + statusCode: StatusCodes.Status400BadRequest), + _ => TypedResults.Problem( + result.FirstError.Description, + statusCode: StatusCodes.Status500InternalServerError), + }; + } + + return TypedResults.Ok(result.Value); + }) + .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) + .WithSummary("Start support session in tenant") + .WithDescription( + "Issues a tenant-scoped, time-bound, audited access token for a SysAdmin or " + + "SysSupport user to act within a specific tenant. Replaces the v1 shadow-row " + + "GrantTenantAccess approach for sys-user cross-tenant operations. Implements " + + "RFC 8693 token exchange semantics via OpenIddict. No refresh token is issued; " + + "the support session is strictly time-bounded."); + } +} +``` + +**Why this is the right shape:** +- Identical file structure to v1 `GrantTenantAccess.cs`: sealed Request/Response records, `IHandler` interface, `internal sealed` implementation, inline validator, static `Map*Endpoint` extension. +- `ErrorOr` return on handler; `TypedResults` switch in endpoint — matches every existing v1 endpoint. +- `RequireAuthorization(RequireSysUserPolicy)` — consistent with v1's `RequireSysAdminPolicy` on admin endpoints. +- TenantAccess gate preserved (locked decision #4). +- Audit log written inside the handler before token creation, consistent with `SaveChangesAsync` in `IdmtDbContext.SaveChangesAsync` audit interceptor pattern. + +--- + +## 5. Token-Exchange Sys-Support Flow and Reference-Token Revocation Wiring + +### 5.1 Token-Exchange Sys-Support Flow + +``` +SysAdmin/SysSupport + │ + │ POST /api/v1/admin/tenants/{tenantIdentifier}/support-session + │ Authorization: Bearer + │ Body: { "requestedLifetime": "01:00:00" } + │ + ▼ +[RequireAuthorization(RequireSysUserPolicy)] + │ OpenIddict validation: unpack reference token → verify against token store + │ TenantValidationHandler: token.Claims["tenant"] == currentTenant.Identifier + │ + ▼ +SupportTenantHandler + │ 1. Resolve invokerUserId from ICurrentUserService + │ 2. Resolve targetTenant from Finbuckle IMultiTenantStore + │ 3. CanAccessTenantAsync(invokerUserId, targetTenant.Id) — TenantAccess gate + │ 4. Build ClaimsPrincipal: sub=invokerUserId, tenant=targetTenant.Identifier, + │ support_session=true, support_invoker=invokerUserId, SysRole forwarded + │ 5. Write AuditLog (SupportSessionStarted) via DbContext.SaveChangesAsync + │ 6. IOpenIddictTokenManager.CreateAsync(descriptor) → opaque reference handle + │ + ▼ +Response: 200 OK { accessToken: "", expiresIn: 3600, tenantIdentifier: "acme" } + +Consumer stores token, uses it for next request to acme-scoped endpoints: + + POST /api/v1/manage/users (acme tenant) + Authorization: Bearer + __tenant__: acme + + ▼ +OpenIddict validation: dereference opaque handle → server-side token row + TenantValidationHandler: token.Claims["tenant"] == "acme" ✓ + CurrentUserMiddleware: populates ICurrentUserService with invoker identity + tenant=acme + Handler proceeds normally under acme tenant context. +``` + +**Revocation of support token:** Call `POST /connect/revocation` with the opaque token. OpenIddict marks the token row `Status = Revoked`. On next use the validation pipeline rejects it with 401. No background cleanup table needed — OpenIddict's `IOpenIddictTokenManager.PruneAsync` removes expired/revoked rows. + +**Explicit session end (admin revokes support access):** +`RevokeTenantAccess` handler (`DELETE /admin/users/{userId}/tenants/{tenantIdentifier}`) is reshaped to additionally call: +```csharp +// enumerate and revoke all valid tokens for this subject + tenant combination +await foreach (var token in tokenManager.FindBySubjectAsync(userId.ToString(), cancellationToken)) +{ + var tenantClaim = await tokenManager.GetClaimValueAsync(token, "tenant", cancellationToken); + if (tenantClaim == targetTenant.Identifier) + await tokenManager.TryRevokeAsync(token, cancellationToken); +} +``` + +### 5.2 Reference-Token Revocation Wiring + +OpenIddict **reference tokens** are the v2 revocation mechanism. The opaque string the client holds is a `ReferenceId`. Every API call: + +1. OpenIddict validation middleware receives `Authorization: Bearer `. +2. Calls `IOpenIddictTokenManager.FindByReferenceIdAsync(opaque)` — single DB lookup. +3. Checks `token.Status == Valid` and `token.ExpirationDate > now`. +4. Rehydrates the `ClaimsPrincipal` from the stored token payload. +5. `TenantValidationHandler` then cross-checks the `tenant` claim. + +Instant revocation events that set `Status = Revoked` on the token row: +- `POST /connect/revocation` (explicit logout or client-side token discard) +- `RevokeTenantAccess` handler (admin removes access) +- `UpdateUserInfo` handler after password/username change (revoke all tokens for user + tenant) +- `UnregisterUser` handler (revoke all tokens for deleted user) +- `DeleteTenant` handler (revoke all tokens with `tenant` = deleted identifier) + +Cleanup: `IOpenIddictTokenManager.PruneAsync` (called periodically via a hosted service registered by `AddOpenIddict()`) removes rows where `ExpirationDate < now || Status == Revoked`. No custom `TokenRevocationCleanupService` needed. + +### 5.3 `TenantValidationHandler` (Replaces `ValidateBearerTokenTenantMiddleware`) + +```csharp +// Idmt.Plugin/Features/Auth/TenantValidationHandler.cs + +internal sealed class TenantValidationHandler( + IMultiTenantContextAccessor tenantContextAccessor, + ILogger logger) + : IOpenIddictValidationHandler +{ + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } + = OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateIdentityModelToken.Descriptor.Order + 500) + .Build(); + + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + var principal = context.Principal; + if (principal is null) return default; + + var currentTenant = tenantContextAccessor.MultiTenantContext?.TenantInfo; + if (currentTenant is null) + { + logger.LogWarning("Bearer token used but no tenant context resolved. Rejecting."); + context.Reject( + error: OpenIddictConstants.Errors.InvalidToken, + description: "No tenant context could be resolved for this request."); + return default; + } + + var tokenTenantClaim = principal.FindFirstValue("tenant"); + if (string.IsNullOrEmpty(tokenTenantClaim)) + { + context.Reject( + error: OpenIddictConstants.Errors.InvalidToken, + description: "The token does not contain a required tenant claim."); + return default; + } + + if (!tokenTenantClaim.Equals(currentTenant.Identifier, StringComparison.Ordinal)) + { + logger.LogWarning( + "Token tenant {TokenTenant} != request tenant {RequestTenant}. Rejecting.", + tokenTenantClaim, currentTenant.Identifier); + context.Reject( + error: OpenIddictConstants.Errors.InvalidToken, + description: "The token was not issued for the requested tenant."); + return default; + } + + return default; + } +} +``` + +--- + +## 6. Test Layout + +### 6.1 `tests/Idmt.UnitTests/` — which v1 tests survive + +``` +tests/Idmt.UnitTests/ +├── Configuration/ +│ ├── IdmtOptionsValidatorTests.cs KEPT (no changes needed) +│ └── RateLimitingOptionsTests.cs KEPT +│ +├── Features/ +│ ├── Auth/ +│ │ ├── ConfirmEmailHandlerTests.cs KEPT +│ │ ├── ConfirmEmailChangeHandlerTests.cs KEPT +│ │ ├── DiscoverTenantsHandlerTests.cs KEPT +│ │ ├── ForgotPasswordHandlerTests.cs KEPT +│ │ ├── LoginHandlerTests.cs RESHAPED → AuthorizeHandlerTests.cs +│ │ │ (cookie sign-in path refactored to OpenIddict authorize flow) +│ │ ├── LogoutHandlerTests.cs RESHAPED → RevokeHandlerTests.cs +│ │ │ (verify OpenIddict token manager revoke is called) +│ │ ├── RefreshTokenHandlerTests.cs DELETED +│ │ │ (OpenIddict owns refresh; no custom handler to test) +│ │ ├── ResendConfirmationEmailHandlerTests.cs KEPT +│ │ ├── ResetPasswordHandlerTests.cs KEPT +│ │ ├── TokenLoginHandlerTests.cs DELETED +│ │ │ (OpenIddict /connect/token tested via integration) +│ │ └── NEW: SupportTenantHandlerTests.cs NEW +│ │ (mock IOpenIddictTokenManager, verify TenantAccess gate, +│ │ verify audit log row, verify support_session claim on principal) +│ │ +│ ├── Admin/ +│ │ ├── CreateTenantHandlerTests.cs KEPT +│ │ ├── DeleteTenantHandlerTests.cs KEPT (add: verify token revoke called) +│ │ ├── GetAllTenantsHandlerTests.cs KEPT +│ │ ├── GetUserTenantsHandlerTests.cs KEPT +│ │ ├── GrantTenantAccessHandlerTests.cs KEPT +│ │ └── RevokeTenantAccessHandlerTests.cs RESHAPED +│ │ (verify IOpenIddictTokenManager.TryRevokeAsync called, not old ITokenRevocationService) +│ │ +│ ├── Manage/ +│ │ ├── GetUserInfoHandlerTests.cs KEPT +│ │ ├── RegisterHandlerTests.cs KEPT +│ │ ├── UnregisterHandlerTests.cs KEPT (add: verify token revoke on delete) +│ │ ├── UpdateUserHandlerTests.cs KEPT +│ │ └── UpdateUserInfoHandlerTests.cs RESHAPED +│ │ (verify IOpenIddictTokenManager.FindBySubjectAsync + TryRevokeAsync +│ │ called on credential change, not old ITokenRevocationService) +│ │ +│ └── Health/ +│ └── BasicHealthCheckTests.cs KEPT +│ +├── Middleware/ +│ ├── CurrentUserMiddlewareTests.cs KEPT +│ └── ValidateBearerTokenTenantMiddlewareTests.cs DELETED +│ (replaced by) +│ └── NEW: TenantValidationHandlerTests.cs NEW +│ (unit test the OpenIddict validation handler in isolation with mocked +│ IMultiTenantContextAccessor; assert Reject() called for wrong tenant) +│ +├── Models/ +│ ├── IdmtTenantInfoTests.cs KEPT +│ ├── IdmtUserTests.cs KEPT +│ └── SysRoleKindTests.cs KEPT +│ +├── Persistence/ +│ └── IdmtDbContextTests.cs RESHAPED +│ (remove RevokedTokens tests; add OpenIddict entity set presence check) +│ +├── Services/ +│ ├── CoreServicesTests.cs KEPT +│ ├── IdmtLinkGeneratorTests.cs KEPT +│ ├── IdmtUserClaimsPrincipalFactoryTests.cs RESHAPED +│ │ (verify "tenant" claim, "sub" = userId, SysRole forwarded; +│ │ remove strategy-option-keyed claim key test) +│ ├── TenantAccessServiceTests.cs KEPT +│ ├── TenantOperationServiceTests.cs KEPT +│ ├── TokenRevocationCleanupServiceTests.cs DELETED +│ └── TokenRevocationServiceTests.cs DELETED +│ +├── Migration/ +│ └── CanonicalIdentityDataMigratorTests.cs KEPT +│ +└── Validation/ + ├── FluentValidatorTests.cs KEPT + └── ValidatorsTests.cs KEPT +``` + +### 6.2 `tests/Idmt.BasicSample.Tests/` — which integration tests survive + +``` +tests/Idmt.BasicSample.Tests/ +├── IdmtApiFactory.cs RESHAPED +│ (replace AddBearerToken with AddOpenIddict; add OpenIddict OIDC client in factory; +│ CreateAuthenticatedClientAsync calls /connect/token instead of /auth/token; +│ mock IEmailSender unchanged) +│ +├── BaseIntegrationTest.cs RESHAPED +│ (CreateAuthenticatedClientAsync: POST /connect/token with grant_type=password +│ or authorization_code; ExtractAccessTokenAsync reads standard OIDC JSON response) +│ +├── AuthIntegrationTests.cs RESHAPED +│ (remove /auth/token tests; add /connect/token happy + error paths; +│ add /connect/revocation test; add invalid-tenant-in-token 401 test) +│ +├── ManageIntegrationTests.cs KEPT (endpoints unchanged) +│ +├── MultiTenancyIntegrationTests.cs RESHAPED +│ (tenant isolation test uses reference tokens; cross-tenant-token rejection +│ test exercises TenantValidationHandler path) +│ +├── Admin/ +│ ├── CreateTenantInvokerAccessTests.cs KEPT +│ ├── GrantTenantAccessIntegrationTests.cs KEPT +│ ├── RevokeTenantAccessIntegrationTests.cs RESHAPED +│ │ (assert token is immediately invalid after revoke, not just DB-flagged) +│ └── NEW: SupportTenantIntegrationTests.cs NEW +│ Scenario tests: +│ - SysAdmin can obtain support token for tenant they have TenantAccess to +│ - Support token works for tenant-scoped endpoints +│ - Support token rejected for different tenant (TenantValidationHandler) +│ - Support token is revoked when RevokeTenantAccess is called +│ - TenantAdmin cannot call /support-session (403) +│ - Support token has no refresh token in response +│ - Expired support token is rejected +│ +├── Auth/ +│ ├── ConfirmEmailChangeIntegrationTests.cs KEPT +│ └── NEW: TokenExchangeIntegrationTests.cs NEW +│ (end-to-end: login → get sys token → exchange for tenant support token → +│ use support token → logout / revoke) +│ +├── Migration/ +│ └── MigrationApplyTests.cs KEPT +│ +└── HttpResponseExtensions.cs KEPT +``` + +### 6.3 New Scenario Tests Required for v2 + +Scenarios not covered by any v1 test: + +1. **Reference token instant revocation**: mint a token, revoke via `/connect/revocation`, assert next use returns 401 (not 403 — the token is invalid, not forbidden). +2. **Support session audit trail**: after `POST /support-session`, verify `IdmtAuditLog` contains a `SupportSessionStarted` row with correct `TenantId`, `UserId`, `IpAddress`. +3. **Support token lifetime cap**: request `requestedLifetime: "8:00:00"` (8 hours > 4-hour cap), assert issued token expires in ≤ 4 hours. +4. **Cross-tenant token rejection via TenantValidationHandler**: issue a valid token for tenant A, use it against tenant B endpoint (different `__tenant__` header), assert 401 with body `"The token was not issued for the requested tenant."`. +5. **TenantAccess gate on support session**: SysAdmin without a `TenantAccess` row for the target tenant gets 401 (not 403), consistent with locked decision #4. +6. **OpenIddict token pruning hook**: confirm that after `DeleteTenant`, all tokens for that tenant's `tenant` claim are marked revoked (unit test mocking `IOpenIddictTokenManager`). +7. **SignalR/WebSocket opaque token via query string**: `GET /hubs/...?access_token=` is validated by OpenIddict validation with `QueryTokenPrefix` hook. + +--- + +## 7. Open Questions and Risks + +### 7.1 OpenIddict Integration Complexity + +**Risk:** OpenIddict's authorization server pipeline (passthrough endpoints, `IOpenIddictApplicationManager` client registration, scope definitions) adds significant startup wiring that v1 did not have. The library must either auto-register a default "idmt" client application at startup (similar to how v1 auto-seeds the default tenant) or document the consumer's responsibility clearly. + +**Proposed resolution:** `AddIdmt` auto-seeds an internal OpenIddict application registration for the "resource server" use case (opaque tokens, no PKCE) via `IHostedService`. A `CustomizeOpenIddict` delegate allows consumers to override or add additional application registrations for their own clients (e.g., a SPA that needs PKCE). Document that calling `services.AddOpenIddict()` independently and then `AddIdmt` will cause conflicts; `AddIdmt` must own the OpenIddict registration. + +### 7.2 `password` Grant Deprecation in OAuth 2.1 + +**Risk:** OAuth 2.1 (draft) removes the `password` grant. `BaseIntegrationTest.CreateAuthenticatedClientAsync` currently posts credentials directly to `/connect/token`. Integration tests will break if/when OpenIddict 5.x drops `password` grant support. + +**Proposed resolution:** Integration tests use the `authorization_code` flow with PKCE and a test-only in-process redirect handler, or OpenIddict's test-mode `AllowNone` grant for unit scenarios. Flag the `password` grant as test-only, not surfaced in production configuration. Alternatively, keep the interactive login slice (`Authorize.cs`) as a custom credential-exchange endpoint that issues an auth code redeemable at `/connect/token`, avoiding `password` grant entirely. + +### 7.3 Cookie + OpenIddict Coexistence + +**Risk:** The v1 `Login.LoginHandler` issues a cookie via `SignInManager.Context.SignInAsync` directly. In v2 the OIDC authorization endpoint passthrough must integrate with the same `SignInManager` for the cookie scheme. OpenIddict's `EnableAuthorizationEndpointPassthrough` hands control to the `Authorize.cs` slice, which calls `SignInAsync` then hands back to OpenIddict. The ordering (Finbuckle MultiTenant middleware → OpenIddict authorization endpoint → sign-in) must be verified. + +**Proposed resolution:** The `Authorize.cs` slice follows OpenIddict's documented "passthrough" pattern exactly. Unit tests for `AuthorizeHandler` mock `HttpContext` using `Microsoft.AspNetCore.Http.Features` to simulate the OpenIddict passthrough context. The sample project (`Idmt.BasicSample`) is updated to demonstrate the full flow. + +### 7.4 `IdmtDbContext` and OpenIddict Entity Coexistence + +**Risk:** `IdmtDbContext` currently inherits `MultiTenantIdentityDbContext`. OpenIddict's `UseEntityFrameworkCore().UseDbContext()` adds four entity sets (`OpenIddictApplication`, `OpenIddictAuthorization`, `OpenIddictScope`, `OpenIddictToken`). If OpenIddict's `OpenIddictDbContext<...>` base conflicts with Finbuckle's base, a manual merge via `OnModelCreating` calling both `base.OnModelCreating` and OpenIddict's model builder is required. + +**Proposed resolution:** Do NOT inherit OpenIddict's `OpenIddictDbContext`. Instead, call `builder.UseOpenIddict()` inside `IdmtDbContext.OnModelCreating` to register the four entity sets via model extension, consistent with OpenIddict's EF Core documentation for non-derived contexts. Validated against OpenIddict 5.x EF Core samples. + +### 7.5 Per-Tenant Cookie Isolation in OpenIddict OIDC Flow + +**Risk:** v1 configures per-tenant cookies via `builder.Services.ConfigurePerTenant(IdentityConstants.ApplicationScheme, ...)`. The OpenIddict authorization endpoint issues cookies via the same `IdentityConstants.ApplicationScheme`. The per-tenant isolation must still work with OpenIddict's passthrough — the tenant context must be set before `SignInAsync` is called in the `Authorize.cs` slice. + +**Proposed resolution:** `Authorize.cs` handler explicitly verifies Finbuckle tenant context before any sign-in call, consistent with the fail-closed pattern in v1's `IdmtUserClaimsPrincipalFactory`. Per-tenant cookie name isolation is preserved because cookie naming is configured at the scheme level, not at OpenIddict level. + +### 7.6 `TenantAccess` Gate in OpenIddict `/connect/token` (Password Grant Path) + +**Risk:** The `password` grant flow in OpenIddict 5.x can be handled by implementing `IOpenIddictServerHandler`. This handler must enforce the `TenantAccess` gate (locked decision #4) before tokens are issued. The handler is called from within the OpenIddict server pipeline, where `IMultiTenantContextAccessor` is available but the Finbuckle tenant context must have been set by middleware before the token endpoint is reached. + +**Proposed resolution:** Register a custom `IOpenIddictServerHandler` in `Features/Auth/Authorize.cs` (or a sibling `TokenEndpointHandler.cs`) that: +1. Resolves the user from the `username`/`password` claims. +2. Calls `tenantAccessService.CanAccessTenantAsync`. +3. If denied, calls `context.Reject(error: "access_denied")`. +This mirrors v1's `Login.TokenLoginHandler` logic, now expressed as an OpenIddict pipeline handler. + +### 7.7 Migration Path from v1 to v2 + +**Risk:** Existing deployments have `RevokedTokens` rows and hand-issued tokens (ASP.NET Core `BearerTokenProtector` format). These tokens are incompatible with OpenIddict reference tokens and cannot be used after migration. + +**Proposed resolution:** +1. Existing v1 tokens are invalidated immediately on upgrade because the validation scheme changes. Clients must re-authenticate. Document this as a breaking change in the upgrade guide. +2. The `RevokedTokens` table can be dropped via a migration; no data migration is needed. +3. Provide a migration checklist in `UPGRADING.md`: (a) run schema migration (adds four OpenIddict tables, drops `RevokedTokens`), (b) auto-seed OpenIddict application registration, (c) notify client teams that all active sessions are terminated on deploy. + +### 7.8 OpenIddict Key Management + +**Risk:** v2 uses `AddDevelopmentEncryptionCertificate()` + `AddDevelopmentSigningCertificate()` in `ConfigureOpenIddict`. These generate in-memory keys that change on every restart, invalidating all reference tokens. Production deployments must supply persistent keys. + +**Proposed resolution:** Add a `IdmtOpenIddictKeyOptions` nested class to `IdmtOptions` with `CertificatePath` / `CertificateThumbprint` / `KeyVaultUri` hooks. If none are configured and the environment is not `Development`, `IdmtOptionsValidator` should emit a warning (not a hard failure) at startup. The `CustomizeOpenIddict` delegate allows consumers to call `options.AddEncryptionCertificate(...)` / `options.AddSigningCertificate(...)` directly. + +--- + +## Appendix A: `IdmtDbContext` v2 Shape (Sketch) + +```csharp +// Idmt.Plugin/Persistence/IdmtDbContext.cs +// Inherits: MultiTenantIdentityDbContext +// OpenIddict entities added via builder.UseOpenIddict() in OnModelCreating + +public class IdmtDbContext + : MultiTenantIdentityDbContext +{ + // Existing DbSets — unchanged + public DbSet AuditLogs { get; set; } = null!; + public DbSet TenantAccess { get; set; } = null!; + // RevokedTokens DELETED + + // OpenIddict entity sets — injected by builder.UseOpenIddict() + // (not declared explicitly; OpenIddict model extension adds them) + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); // Finbuckle + Identity + + // Register OpenIddict entities without inheriting OpenIddictDbContext + builder.UseOpenIddict(); + + // [IdmtUser global entity de-tenanting — identical to v1] + builder.Entity(entity => { /* unchanged */ }); + + // [IdmtRole, TenantAccess, AuditLog, TenantInfo configs — unchanged] */ + + // RevokedToken config DELETED + } +} +``` + +--- + +## Appendix B: `AuthEndpoints.cs` v2 Shape (Sketch) + +```csharp +// Idmt.Plugin/Features/AuthEndpoints.cs +public static class AuthEndpoints +{ + internal const string AuthRateLimiterPolicy = "idmt-auth"; + + public static void MapAuthEndpoints(this IEndpointRouteBuilder endpoints) + { + var idmtOptions = endpoints.ServiceProvider + .GetRequiredService>().Value; + + var auth = endpoints.MapGroup("/auth") + .WithTags("Authentication"); + + if (idmtOptions.RateLimiting.Enabled) + auth.RequireRateLimiting(AuthRateLimiterPolicy); + + // OIDC endpoints — OpenIddict passthrough + endpoints.MapAuthorizeEndpoint(); // GET+POST /connect/authorize + endpoints.MapTokenEndpoint(); // POST /connect/token + endpoints.MapRevocationEndpoint(); // POST /connect/revocation + endpoints.MapUserInfoEndpoint(); // GET /connect/userinfo + + // v1-identical endpoints + auth.MapConfirmEmailEndpoint(); + auth.MapConfirmEmailDirectEndpoint(); + auth.MapConfirmEmailChangeEndpoint(); + auth.MapResendConfirmationEmailEndpoint(); + auth.MapForgotPasswordEndpoint(); + auth.MapResetPasswordEndpoint(); + auth.MapDiscoverTenantsEndpoint(); + + // DELETED: MapCookieLoginEndpoint, MapTokenLoginEndpoint, MapRefreshTokenEndpoint + } +} +``` + +--- + +*End of ADR 0002.* \ No newline at end of file diff --git a/adr/0002-v2-sketch-dotnet-expert.md b/adr/0002-v2-sketch-dotnet-expert.md new file mode 100644 index 0000000..7dfe98e --- /dev/null +++ b/adr/0002-v2-sketch-dotnet-expert.md @@ -0,0 +1,551 @@ +# ADR 0002 — IDMT v2 Architecture Sketch (.NET Expert Lens) + +- **Status:** Draft / Design Sketch (one of several competing proposals) +- **Date:** 2026-06-04 +- **Author lens:** .NET 10 / C# 14 specialist +- **Supersedes (if adopted):** the hand-rolled token machinery in IDMT v1 +- **Scope:** Design artifact only. No implementation. Signatures and structure are illustrative. + +--- + +## 0. Thesis in one paragraph + +IDMT v2 stops being an identity-provider and becomes a **multi-tenant authorization shell around OpenIddict**. OpenIddict owns every commodity OAuth2/OIDC concern (authorize/token/introspection/revocation/userinfo, refresh rotation, reference tokens, token exchange). ASP.NET Core Identity stays as the user store. Finbuckle stays as the tenant resolver. IDMT contributes exactly three things of its own: (1) the **canonical-identity + TenantAccess + SysRole authorization model** projected into tokens, (2) **opinionated-but-overridable wiring** that glues OpenIddict + Identity + Finbuckle together correctly for multi-tenancy, and (3) **endpoint scaffolding** that hands the consumer pre-authorized route groups for both the tenant side and the sys-admin side. Everything else gets deleted. + +The architectural bet: *own the policy, rent the protocol.* + +--- + +## 1. Solution & Project Layout + +Multi-package, layered, dependency arrows point inward. net10.0 everywhere, `14`, `enable`, `true`, `ImplicitUsings` on. + +``` +Idmt.slnx +├── src/ +│ ├── Idmt.Abstractions/ ──► (no IDMT deps) [NuGet: Idmt.Abstractions] +│ │ Contracts only. Interfaces, options records, claim/scope/policy +│ │ name constants, ErrorOr error catalog, marker delegates. +│ │ Refs: ErrorOr, Microsoft.Extensions.* abstractions only. +│ │ +│ ├── Idmt.Core/ ──► Abstractions [NuGet: Idmt.Core] +│ │ The authorization MODEL (no web, no OpenIddict types leaking). +│ │ - Entities: IdmtUser : IdentityUser, IdmtRole, TenantAccess, +│ │ SysRoleAssignment (table per ADR 0001), audit aggregates. +│ │ - ITenantAccessService, ISysRoleService, ICurrentPrincipalAccessor. +│ │ - The "claims projection" engine (IdmtClaimsProjector) — the single +│ │ source of truth for what TenantAccess/SysRole means as claims/scopes. +│ │ Refs: AspNetCore.Identity.Stores, EFCore.Abstractions. +│ │ +│ ├── Idmt.Server/ ──► Core [NuGet: Idmt.Server] ◄── MAIN PACKAGE +│ │ The OpenIddict integration + ASP.NET wiring + endpoint scaffolding. +│ │ - AddIdmt(...) entry point and the builder graph (§2). +│ │ - OpenIddict server/validation registration + IDMT handlers that +│ │ inject tenant/SysRole/TenantAccess into issued tokens. +│ │ - Token-exchange (RFC 8693) handler for sys-support (§4). +│ │ - Reference-token + Finbuckle wiring (§5, §6). +│ │ - Endpoint groups: MapIdmtTenantApi / MapIdmtSysAdminApi (§3). +│ │ Refs: OpenIddict.AspNetCore, OpenIddict.EntityFrameworkCore, +│ │ Finbuckle.MultiTenant.AspNetCore, FluentValidation, ErrorOr. +│ │ +│ ├── Idmt.Persistence.EfCore/ ──► Core [NuGet: Idmt.Persistence.EntityFrameworkCore] +│ │ IdmtDbContext, IdmtTenantStoreDbContext, OpenIddict store mapping, +│ │ entity configs, model-building extensions. Provider-agnostic. +│ │ +│ └── Idmt.Mfa/ ──► Core [NuGet: Idmt.Mfa] (optional add-on) +│ TOTP (Identity token providers) now; fido2-net-lib WebAuthn later. +│ Kept out of the main package so the WebAuthn dependency is opt-in. +│ +├── tools/ +│ └── Idmt.Migrator/ console — v1→v2 data migration harness +│ +├── samples/ +│ └── Idmt.Sample.Host/ reference host wiring both endpoint groups +│ +└── tests/ + ├── Idmt.Core.UnitTests/ xUnit + EF InMemory + TimeProvider.Testing + ├── Idmt.Server.IntegrationTests/ WebApplicationFactory + SQLite + Testcontainers(pg) + └── Idmt.Server.Benchmarks/ BenchmarkDotNet (token issuance, claims projection) +``` + +### Dependency direction (strict) + +``` +Abstractions ◄── Core ◄── { Server, Persistence.EfCore, Mfa } +``` + +`Idmt.Abstractions` has **zero** dependency on OpenIddict, Finbuckle, or ASP.NET. This is what lets a consumer reference contracts (e.g. to implement `ITenantAccessStore`) without dragging the whole server runtime, and it's what keeps OpenIddict swappable in theory and testable in practice. + +### Why OpenIddict lives only in `Idmt.Server` + +OpenIddict types (`OpenIddictRequest`, `OpenIddictServerBuilder`, the event-handler model) are deliberately *not* exposed by Core or Abstractions. The integration is a leaf. If OpenIddict's API churns across a major version, only one project recompiles. The consumer never types `using OpenIddict.*` unless they explicitly reach for the `customizeServer` escape hatch (§2.4). + +--- + +## 2. Module / Registration Design + +### 2.1 Entry point + +```csharp +namespace Idmt.Server; + +public static class IdmtServiceCollectionExtensions +{ + extension(IServiceCollection services) // C# 14 extension block + { + public IIdmtBuilder AddIdmt( + IConfiguration configuration, + Action? configure = null) + => AddIdmt(configuration, configure); + + public IIdmtBuilder AddIdmt( + IConfiguration configuration, + Action? configure = null) + where TDbContext : IdmtDbContext; + } +} +``` + +The old positional-delegate soup (`configureDb`, `configureOptions`, `customizeAuthentication`, `customizeAuthorization`) is replaced by **a single options object that returns a fluent builder**. The builder is the override seam; the options object holds declarative config; both are validated `OnStart`. + +### 2.2 The builder graph + +`AddIdmt` returns `IIdmtBuilder`, deliberately mirroring how OpenIddict and Identity compose so it feels native: + +```csharp +public interface IIdmtBuilder +{ + IServiceCollection Services { get; } + + IIdmtBuilder UseEntityFrameworkCore(Action configureDb); + IIdmtBuilder AddMultiTenancy(Action configure); + IIdmtBuilder AddServer(Action configure); // wraps OpenIddict server + IIdmtBuilder AddValidation(Action configure); // wraps OpenIddict validation + IIdmtBuilder AddMfa(Action configure); // from Idmt.Mfa + IIdmtBuilder ConfigureAuthorization(Action configure); // raw ASP.NET seam +} +``` + +Typical host wiring (opinionated defaults already applied; this is the *override* surface): + +```csharp +builder.Services + .AddIdmt(builder.Configuration, o => + { + o.Issuer = new Uri("https://id.example.com"); + o.AccessTokenFormat = IdmtTokenFormat.Reference; // opaque + instant revocation (default) + o.RefreshTokenTtl = TimeSpan.FromDays(14); + o.SupportTokenTtl = TimeSpan.FromMinutes(15); // sys-support exchange ceiling + }) + .UseEntityFrameworkCore(db => db.UseNpgsql(cs)) + .AddMultiTenancy(t => t + .ResolveBy(IdmtTenantStrategy.Route, IdmtTenantStrategy.Header) + .WithPerTenantCookies() + .WithRouteParameter("__tenant__")) + .AddServer(s => s + .AllowPasswordFlow() // first-party tenant login + .AllowRefreshTokenFlow() + .AllowTokenExchangeFlow() // RFC 8693 — sys-support + .EnableDegradedModeOff()) // we register real EF stores + .AddValidation(v => v.UseLocalServer()) // introspect reference tokens in-process + .AddMfa(m => m.AddTotp()); +``` + +### 2.3 Opinionated defaults vs. override seams + +| Concern | Opinionated default (zero-config) | Override seam | +|---|---|---| +| Access token format | **Reference (opaque)** for instant revocation | `o.AccessTokenFormat = Jwt` | +| Flows | password + refresh + token-exchange | `AddServer(s => ...)` | +| Token TTLs | access 10 min / refresh 14 d / support 15 min | options record | +| Endpoints | `/connect/token`, `/connect/userinfo`, `/connect/introspect`, `/connect/revoke` (OpenIddict conventions) | `IdmtServerBuilder.SetTokenEndpointUris(...)` | +| Authorization policies | the named policies in §3.4, pre-registered | `ConfigureAuthorization(...)` | +| Tenant resolution | Route → Header fallback | `AddMultiTenancy(...)` | +| Claims projection | `IdmtClaimsProjector` (TenantAccess + SysRole → claims/scopes) | replace via `services.Replace()` | +| Signing/encryption keys | dev: ephemeral; prod: **fails fast** unless configured | `IdmtServerBuilder.AddSigningCertificate(...)` | + +Defaults are productive on day one but *refuse to silently ship insecure prod config* (ephemeral keys throw under `IsProduction()`). + +### 2.4 How OpenIddict is wrapped (not hidden) + +`IdmtServerBuilder` is a thin facade that (a) sets IDMT's opinionated OpenIddict options, (b) registers IDMT's event handlers, and (c) exposes a typed escape hatch for everything IDMT doesn't model: + +```csharp +public sealed class IdmtServerBuilder +{ + public IdmtServerBuilder AllowPasswordFlow(); + public IdmtServerBuilder AllowRefreshTokenFlow(); + public IdmtServerBuilder AllowTokenExchangeFlow(); + public IdmtServerBuilder UseReferenceAccessTokens(bool enabled = true); + public IdmtServerBuilder AddSigningCertificate(X509Certificate2 cert); + public IdmtServerBuilder AddEncryptionCertificate(X509Certificate2 cert); + + /// Raw OpenIddict for anything IDMT does not opinion about (custom claims + /// destinations, extra grant types, scope handling). IDMT applies its own + /// config first, then invokes this, so the consumer always wins. + public IdmtServerBuilder Configure(Action configure); +} +``` + +Internally `AddServer` does roughly: + +```csharp +services.AddOpenIddict() + .AddCore(c => c.UseEntityFrameworkCore().UseDbContext()) + .AddServer(server => + { + server.SetTokenEndpointUris("connect/token") + .SetUserinfoEndpointUris("connect/userinfo") + .SetIntrospectionEndpointUris("connect/introspect") + .SetRevocationEndpointUris("connect/revoke"); + + server.UseReferenceAccessTokens(); // §5 + server.AllowRefreshTokenFlow(); + server.AllowCustomFlow(IdmtGrants.TokenExchange); // urn:ietf:params:oauth:grant-type:token-exchange + + // IDMT's own pipeline handlers — the heart of the library: + server.AddEventHandler(IdmtPasswordGrantHandler.Descriptor); // validates TenantAccess gate + server.AddEventHandler(IdmtTokenExchangeHandler.Descriptor); // sys-support (§4) + server.AddEventHandler(IdmtClaimsDestinationHandler.Descriptor);// projects TenantAccess/SysRole + + server.UseAspNetCore().EnableTokenEndpointPassthrough(); + + builderConsumerOverride?.Invoke(server); // consumer wins last + }) + .AddValidation(v => { v.UseLocalServer(); v.UseAspNetCore(); }); +``` + +### 2.5 Options + validation (fail fast) + +```csharp +public sealed record IdmtBuilderOptions +{ + public required Uri Issuer { get; set; } + public IdmtTokenFormat AccessTokenFormat { get; set; } = IdmtTokenFormat.Reference; + public TimeSpan AccessTokenTtl { get; set; } = TimeSpan.FromMinutes(10); + public TimeSpan RefreshTokenTtl { get; set; } = TimeSpan.FromDays(14); + public TimeSpan SupportTokenTtl { get; set; } = TimeSpan.FromMinutes(15); + public bool RequireConfirmedEmail { get; set; } = true; + public IdmtRateLimitOptions RateLimiting { get; init; } = new(); +} +``` + +```csharp +services.AddOptions() + .Bind(configuration.GetSection("Idmt")) + .Configure(configure) + .Validate(o => o.SupportTokenTtl <= TimeSpan.FromMinutes(60), + "Idmt:SupportTokenTtl must not exceed 60 minutes.") + .ValidateDataAnnotations() + .ValidateOnStart(); // surfaces misconfig at boot, not first request +``` + +`ValidateOnStart()` (plus a hosted `IValidateOptions` for cross-field rules like "Reference tokens require EF stores, not degraded mode") replaces v1's hand-rolled `IdmtOptionsValidator.Validate(null, ...)` call inside registration. + +--- + +## 3. Public API Sketch + +### 3.1 Carried-forward model types (from ADR 0001) + +```csharp +public class IdmtUser : IdentityUser // GLOBAL canonical identity +{ + public string? PendingEmail { get; set; } + public DateTimeOffset? PendingEmailExpiresAt { get; set; } +} + +public class IdmtRole : IdentityRole // PER-TENANT +{ + public string TenantId { get; set; } = default!; +} + +public sealed class TenantAccess // user ↔ tenant edge +{ + public Guid UserId { get; set; } + public string TenantId { get; set; } = default!; + public bool IsActive { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } +} + +public enum SysRoleKind { None = 0, SysAdmin = 1, SysSupport = 2 } +``` + +### 3.2 Claims projection — the one piece of "secret sauce" + +The single place that decides how the authorization model becomes token content. Pure, testable, no OpenIddict types in the signature: + +```csharp +namespace Idmt.Abstractions; + +public interface IIdmtClaimsProjector +{ + /// Produces the IDMT claim set for a (user, tenant, sysRole, purpose) tuple. + /// Called by the OpenIddict pipeline handler during token issuance. + ValueTask ProjectAsync(IdmtPrincipalContext context, CancellationToken ct); +} + +public sealed record IdmtPrincipalContext( + Guid UserId, + string? TenantId, + SysRoleKind SysRole, + IdmtTokenPurpose Purpose); // TenantAccess | SysAdmin | SupportSession + +public sealed record IdmtClaimSet( + IReadOnlyList Claims, + IReadOnlyList Scopes, + string? Audience); + +public enum IdmtTokenPurpose { TenantAccess, SysAdmin, SupportSession } +``` + +### 3.3 Endpoint scaffolding — the "opinionated but customizable" core + +Two route-group factories. Each returns a `RouteGroupBuilder` with the **right policy already attached**, so the consumer can append their own endpoints into a correctly-authorized group, or take the batteries-included defaults. + +```csharp +namespace Idmt.Server; + +public static class IdmtEndpointRouteBuilderExtensions +{ + extension(IEndpointRouteBuilder app) + { + /// Tenant-facing surface. Resolves tenant via Finbuckle, requires a + /// tenant-scoped access token, applies the auth rate-limiter. + /// includeBuiltIn maps: /me (userinfo proxy), /manage/info, + /// /manage/password, /manage/email-change, /manage/mfa. + public RouteGroupBuilder MapIdmtTenantApi( + string prefix = "/api", + bool includeBuiltIn = true); + + /// System-admin surface. Requires RequireSysAdmin. includeBuiltIn maps: + /// tenant CRUD, grant/revoke TenantAccess, SysRole assignment, + /// active-session listing, and POST /sys/support/{tenantId} (§4). + public RouteGroupBuilder MapIdmtSysAdminApi( + string prefix = "/sys", + bool includeBuiltIn = true); + } +} +``` + +Host usage — the balance point in action: + +```csharp +// Take the defaults, then bolt on consumer endpoints inside the pre-authorized group. +var tenant = app.MapIdmtTenantApi(); // built-ins + tenant policy + rate limit +tenant.MapGet("/assets", ListAssets); // inherits tenant authorization + +var sys = app.MapIdmtSysAdminApi(includeBuiltIn: false); // I want ONLY my own sys endpoints +sys.MapGet("/tenants/{id}/usage", GetTenantUsage); // already RequireSysAdmin + +// OpenIddict's own protocol endpoints are mapped separately (passthrough handlers): +app.MapIdmtServerEndpoints(); // /connect/token, /userinfo, /introspect, /revoke +``` + +Built-in endpoints follow v1's vertical-slice shape but **thinner**: a slice is now `{ Request record, ErrorOr handler, FluentValidation validator, mapper }` — minus everything OpenIddict now owns. Mappers use `TypedResults`: + +```csharp +internal static RouteHandlerBuilder MapChangePassword(this RouteGroupBuilder g) => + g.MapPost("/manage/password", + async (ChangePasswordRequest req, IChangePasswordHandler h, CancellationToken ct) + => (await h.HandleAsync(req, ct)).ToHttpResult()) // ErrorOr → Results + .WithName("Idmt.ChangePassword"); +``` + +### 3.4 Authorization policy names (constants in Abstractions) + +```csharp +public static class IdmtPolicies +{ + public const string RequireSysAdmin = "Idmt:RequireSysAdmin"; + public const string RequireSysUser = "Idmt:RequireSysUser"; // SysAdmin or SysSupport + public const string RequireTenantManager= "Idmt:RequireTenantManager"; + public const string RequireTenantMember = "Idmt:RequireTenantMember"; // new: any active TenantAccess + public const string SupportSessionOnly = "Idmt:SupportSessionOnly"; // tokens minted via §4 +} + +public static class IdmtScopes +{ + public const string Tenant = "idmt.tenant"; + public const string SysAdmin= "idmt.sys"; + public const string Support = "idmt.support"; +} +``` + +Policies bind to OpenIddict's validation handler (not cookie/bearer schemes from v1). `RequireTenantMember` is a **resource-based** check: it compares the token's `tenant` claim against the Finbuckle-resolved tenant for the request — this is the v2 successor to v1's `ValidateBearerTokenTenantMiddleware`, now expressed as an `IAuthorizationHandler` rather than middleware. + +--- + +## 4. Token-Exchange / Sys-Support Flow (RFC 8693) + +**Goal:** a SysAdmin/SysSupport user "drops into" a tenant for a bounded, audited window — *without* shadow rows, without a second login, and with instant revocability. + +### 4.1 Flow + +``` +SysAdmin already holds a sys token (scope=idmt.sys, purpose=SysAdmin). + +POST /connect/token + grant_type = urn:ietf:params:oauth:grant-type:token-exchange + subject_token = (reference token) + subject_token_type = urn:ietf:params:oauth:token-type:access_token + scope = idmt.support + resource = https://id.example.com/tenants/acme (target tenant) + // optional: reason=, captured for audit + + │ + ▼ +IdmtTokenExchangeHandler (OpenIddict event handler): + 1. Require caller principal has SysRole ∈ {SysAdmin, SysSupport}. else → forbidden + 2. Resolve target tenant from `resource`; assert it exists/active. + 3. Mint a NEW reference access token via the projector with + purpose = SupportSession, tenant = acme, + ttl = min(options.SupportTokenTtl, remaining sys-token life), + claims: sub=, act={ sub= } (RFC 8693 actor claim), + idmt:support=true, idmt:reason=. + 4. Write SupportSessionAudit row (immutable): actor, tenant, reason, + issuedAt, expiresAt, jti, ip, userAgent. <-- mandatory + 5. Return the tenant-scoped support token (no refresh token issued). +``` + +Key properties: +- **No new account, no shadow row.** The support token's `sub` is the sys user; the `act` (actor) claim makes the impersonation explicit and auditable per the RFC. +- **Non-extendable.** No refresh token is issued for support sessions; when it expires, the sys user must re-exchange (re-audited each time). +- **Bounded by `SupportTokenTtl`** AND by the parent sys token's remaining life (can't outlive the grant). +- **Tenant pages can't tell the difference** at the authorization layer (it's a normal tenant-scoped token) but logs/audit always can (`idmt:support=true`, `act` claim). + +### 4.2 Surface + +```csharp +public interface ISupportSessionService +{ + ValueTask> BeginAsync( + Guid sysUserId, string targetTenantId, string? reason, CancellationToken ct); +} + +public sealed record SupportSession( + string AccessToken, string TenantId, DateTimeOffset ExpiresAt, string Jti); +``` + +The exchange handler delegates to this service; the service is also what the audit and revocation paths key off `Jti`. The built-in `POST /sys/support/{tenantId}` endpoint is a thin convenience wrapper over the standard `/connect/token` exchange for consumers who prefer a named endpoint. + +--- + +## 5. Reference-Token Revocation (Instant) + +Because access tokens are **reference (opaque)**, every API call introspects a server-side token record. Revocation is therefore a single store update — no waiting for short JWT expiry, no denylist gymnastics. + +``` +Issue: OpenIddict persists an OpenIddictToken row (status=valid) and returns + an opaque handle as the access token. +Validate:Idmt.Server uses OpenIddict *local* validation (UseLocalServer) — it + reads the token row in-process on each request and rejects if + status != valid or past expiry. +Revoke: flip the row(s) to status=revoked → next request fails instantly. +``` + +IDMT layers a small fan-out service on top so business events map to revocations: + +```csharp +public interface IIdmtTokenRevoker +{ + ValueTask RevokeTokenAsync(string jti, CancellationToken ct); + ValueTask RevokeUserTokensAsync(Guid userId, CancellationToken ct); // password change, etc. + ValueTask RevokeTenantAccessAsync(Guid userId, string tenantId, CancellationToken ct); // access revoked + ValueTask RevokeSupportSessionAsync(string jti, CancellationToken ct); +} +``` + +Wired to model events: +- `SecurityStamp` change / password reset → `RevokeUserTokensAsync` (single canonical user → all tokens; the v1 shadow-row propagation bug is structurally gone). +- `TenantAccess.IsActive = false` or `ExpiresAt` passed → `RevokeTenantAccessAsync`. +- SysAdmin "end support" → `RevokeSupportSessionAsync(jti)`. + +Background hygiene: a `BackgroundService` prunes expired/revoked OpenIddict token + authorization rows (replaces v1's `TokenRevocationCleanupService`; OpenIddict ships `OpenIddictQuartz`/pruning hooks we can reuse instead of hand-rolling). + +**Performance note:** reference tokens add a DB read per request. Mitigate with a short-TTL in-memory cache of *valid* token records keyed by handle, invalidated on revoke via the revoker (cache the positive, never the negative). Benchmarked in `Idmt.Server.Benchmarks`. This is the central performance/security tradeoff of v2 and should be measured, not assumed. + +--- + +## 6. Multi-Tenancy Integration (Finbuckle × OpenIddict × cookies) + +Three concerns must coexist on one host. The integration rules: + +### 6.1 Tenant resolution ordering +Finbuckle middleware runs **before** OpenIddict's pipeline so the OpenIddict server endpoints (`/connect/token`) see a resolved tenant. For first-party password login the tenant comes from the route (`/{__tenant__}/connect/token`) or a header; for token-exchange it comes from the `resource` parameter (§4), cross-checked against Finbuckle. + +``` +[ Finbuckle.UseMultiTenant ] + → [ Idmt tenant-coherence middleware (assert resolved) ] + → [ OpenIddict validation/server ] + → [ Authorization (RequireTenantMember resource check) ] + → endpoints +``` + +### 6.2 Per-tenant cookies vs. the token server +v1 isolated **cookies** per tenant (`ConfigurePerTenant`). v2 keeps that *only for the interactive sign-in surface* (the authorize-code/MFA UI, if used). API auth is **bearer reference tokens**, which carry their tenant in a claim — no per-tenant cookie needed for APIs. So: +- Cookies (per-tenant, Finbuckle-isolated): the human-facing login/consent pages only. +- Tokens (tenant claim + resource audience): all API traffic. + +This collapses v1's "hybrid cookie/bearer per request" complexity: the *cookie session* exists to obtain a *token*; resource servers only ever see tokens. + +### 6.3 Signing keys per tenant? +**Decision: shared issuer, single signing key, tenant as a claim/audience.** Per-tenant keys/issuers are a documented non-goal for v1 parity (one trust domain, owner-controlled infra). Left as an open question (§8) only if a hard tenant-cryptographic-isolation requirement appears. + +### 6.4 OpenIddict stores are tenant-tagged but globally stored +OpenIddict's token/application/authorization tables live in `IdmtDbContext` (the canonical, *not* per-tenant-row-filtered store for these tables), with `tenant` carried as token payload + audience. This avoids fighting Finbuckle's global query filters on the OAuth plumbing while still enforcing tenant scope at the authorization layer. + +--- + +## 7. v1 Code: Deleted vs. Kept + +### Deleted (OpenIddict / Identity now own it) +- `Features/Auth/Login.cs` (`LoginHandler`, `TokenLoginHandler`) → OpenIddict password grant + IDMT TenantAccess-gate handler. +- `Features/Auth/RefreshToken.cs` → OpenIddict refresh-token flow + rotation. +- `Features/Auth/Logout.cs` token side → OpenIddict revocation endpoint. +- `Services/TokenRevocationService.cs`, `ITokenRevocationService`, `Models/RevokedToken.cs` → OpenIddict reference-token status + `IIdmtTokenRevoker` facade. +- `Services/TokenRevocationCleanupService.cs` → OpenIddict pruning. +- `Middleware/ValidateBearerTokenTenantMiddleware.cs` → `RequireTenantMember` authorization handler (§3.4). +- `AddBearerToken` / `AddPolicyScheme` (`CookieOrBearerScheme`) wiring → OpenIddict validation; PolicyScheme no longer needed because API auth is uniformly bearer. +- Hand-rolled bearer query-string token plumbing for WebSockets → OpenIddict validation + a small token extractor for the SignalR path. +- The "duplicate account into tenant" cross-tenant access path (already partly gone in v1) → token exchange (§4). + +### Kept / carried forward +- Canonical `IdmtUser`, `IdmtRole`, `TenantAccess`, `SysRoleAssignment` model (ADR 0001) — moved to `Idmt.Core`. +- `ITenantAccessService` and the **uniform TenantAccess login gate** (now a pre-issuance OpenIddict handler, still uniform incl. SysAdmin). +- Finbuckle multi-tenant resolution + `IdmtTenantInfo` + `IdmtTenantStoreDbContext`. +- Email flows (confirm/forgot/reset/email-change) and `IIdmtLinkGenerator`, `IEmailSender` — these remain IDMT's, now sitting on the token foundation. +- `PiiMasker`, audit aggregates, `ICurrentUserService` (renamed `ICurrentPrincipalAccessor`). +- Vertical-slice shape for the *remaining* business endpoints (manage/admin), `ErrorOr` + FluentValidation. +- Rate limiting (built-in middleware) on the token + email endpoints. +- The v1→v2 data migration harness (`tools/Idmt.Migrator`). + +--- + +## 8. Open Questions / Risks (.NET 10 + OpenIddict + Finbuckle) + +1. **Finbuckle global query filters vs. OpenIddict EF stores.** OpenIddict's `OpenIddictEntityFrameworkCore` stores query their tables directly; if `IdmtDbContext` applies a Finbuckle `HasQueryFilter` broadly, OpenIddict reads could be silently tenant-filtered and break token validation. Mitigation: keep OpenIddict tables out of the multi-tenant filter set (own `DbContext` partition or explicit `IgnoreQueryFilters` mapping). **Needs a prototype to confirm composition order.** +2. **Reference-token read amplification.** One DB round-trip per request. The positive-cache mitigation (§5) must be validated under load; revocation-cache invalidation across multiple app instances needs a backplane (Redis pub/sub or DB change polling) — otherwise instant revocation degrades to cache-TTL revocation in a scaled-out deployment. This is the single biggest production risk. +3. **Token-exchange (RFC 8693) maturity in OpenIddict.** Token exchange is supported via custom-flow registration but is less turnkey than password/refresh; the actor (`act`) claim handling and `resource`→tenant mapping are bespoke handlers we own. Risk of OpenIddict API drift around custom grants across majors. +4. **Tenant resolution for the token endpoint.** Route-based tenant in the OAuth path (`/{tenant}/connect/token`) deviates from standard single-issuer OIDC discovery. Header/`resource`-based resolution is cleaner but means generic OIDC clients need IDMT-aware configuration. **Pick one and document the OIDC-conformance tradeoff.** +5. **Per-tenant cookies + OpenIddict authorize UI.** If interactive flows (authorize-code, MFA challenge pages) are enabled, Finbuckle per-tenant cookie isolation must coexist with OpenIddict's own auth/consent cookies — name-collision and SameSite interplay to verify. +6. **AOT / trimming.** OpenIddict and EF Core are not fully Native-AOT friendly today; the "AOT-ready" aspiration from the v1 checklist is likely **out of reach** for the server package. Scope AOT only to the (hypothetical) validation-only edge package if pursued. +7. **Single signing key vs. tenant crypto-isolation.** §6.3 assumes one trust domain. If a future requirement demands per-tenant key isolation, OpenIddict's single-issuer model fights it — would need multiple OpenIddict server instances or a custom key-selection handler. Flagged, not solved. +8. **Migration of live sessions.** v1 issues bearer tokens via `AddBearerToken`; v2 issues OpenIddict reference tokens. There is no in-place token translation — cutover requires forcing re-authentication (acceptable, but must be sequenced with the ADR 0001 data migration). + +--- + +## 9. Summary table (for side-by-side comparison) + +| Dimension | This sketch's stance | +|---|---| +| Package count | 5 src packages; `Idmt.Server` is the main one | +| OpenIddict location | leaf (`Idmt.Server` only), wrapped by `IdmtServerBuilder` facade + raw escape hatch | +| Entry point | `AddIdmt()` → fluent `IIdmtBuilder` (no positional-delegate soup) | +| Access tokens | reference/opaque by default → instant revocation | +| Sys-support | RFC 8693 token exchange, `act` claim, mandatory audit, no refresh | +| Endpoint scaffolding | `MapIdmtTenantApi` / `MapIdmtSysAdminApi` returning pre-authorized `RouteGroupBuilder`s | +| v1 tenant-token middleware | replaced by `RequireTenantMember` authorization handler | +| Biggest risk | reference-token read amplification + cross-instance revocation backplane | +| Distinctive bet | "own the policy, rent the protocol"; claims projection is the one piece of IDMT secret sauce | +``` diff --git a/samples/Idmt.BasicSample/SeedTestUser.cs b/samples/Idmt.BasicSample/SeedTestUser.cs index 1a425da..ec6b9ac 100644 --- a/samples/Idmt.BasicSample/SeedTestUser.cs +++ b/samples/Idmt.BasicSample/SeedTestUser.cs @@ -45,14 +45,14 @@ public static async Task SeedAsync(IServiceProvider services) return; // User already exists } - // Create test user + // Create test user (Phase 1: IdmtUser is global — no TenantId column) var user = new IdmtUser { Email = TestUserEmail, UserName = "testadmin", EmailConfirmed = true, IsActive = true, - TenantId = tenant.Id! + SysRole = SysRoleKind.SysAdmin, }; var result = await userManager.CreateAsync(user, TestUserPassword); diff --git a/spike/Idmt.Spike.slnx b/spike/Idmt.Spike.slnx new file mode 100644 index 0000000..d02ab31 --- /dev/null +++ b/spike/Idmt.Spike.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/spike/src/Idmt.Spike.Host/Auth/Auth.cs b/spike/src/Idmt.Spike.Host/Auth/Auth.cs new file mode 100644 index 0000000..c0d5a2c --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Auth/Auth.cs @@ -0,0 +1,93 @@ +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +using Microsoft.EntityFrameworkCore; +using OpenIddict.Abstractions; +using OpenIddict.Validation; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Validation.OpenIddictValidationEvents; + +namespace Idmt.Spike.Host.Auth; + +/// Per-tenant audience URN, the RFC 8707 resource value. +public static class TenantUrns +{ + public const string Prefix = "urn:idmt:tenant:"; + + public static string For(string identifier) => Prefix + identifier; + + public static string? IdentifierFrom(string urn) => + urn.StartsWith(Prefix, StringComparison.Ordinal) ? urn[Prefix.Length..] : null; +} + +/// +/// The uniform TenantAccess gate. Queried at token issuance for every grant, +/// with no reliance on an ambient tenant (the token endpoint has none). +/// +public interface ITenantAccessGate +{ + Task CanAccessAsync(Guid userId, string tenantIdentifier, CancellationToken ct); +} + +public sealed class TenantAccessGate(IdmtIdentityDbContext db, TimeProvider clock) : ITenantAccessGate +{ + public async Task CanAccessAsync(Guid userId, string tenantIdentifier, CancellationToken ct) + { + var now = clock.GetUtcNow(); + // SQLite cannot translate the DateTimeOffset comparison, so filter the + // translatable predicate in SQL and evaluate expiry in memory. The + // candidate set is at most a handful of rows per (user, tenant). + var candidates = await db.TenantAccess + .Where(ta => ta.UserId == userId && ta.TenantId == tenantIdentifier && ta.IsActive) + .Select(ta => ta.ExpiresAt) + .ToListAsync(ct); + + return candidates.Any(expiresAt => expiresAt == null || expiresAt > now); + } +} + +/// +/// Gate 3: the IDMT-owned per-request audience handler. Successor to v1's +/// ValidateBearerTokenTenantMiddleware, relocated into the OpenIddict validation +/// pipeline. Rejects any token whose audience does not bind to the +/// Finbuckle-resolved tenant. Runs after the built-in handlers establish the +/// principal (UseMultiTenant must run before UseAuthentication so the accessor +/// is populated). +/// +public sealed class TenantAudienceValidationHandler(IMultiTenantContextAccessor accessor) + : IOpenIddictValidationHandler +{ + public static OpenIddictValidationHandlerDescriptor Descriptor { get; } = + OpenIddictValidationHandlerDescriptor.CreateBuilder() + .UseScopedHandler() + // Run after every built-in authentication handler has populated the principal. + .SetOrder(int.MaxValue - 100_000) + .SetType(OpenIddictValidationHandlerType.Custom) + .Build(); + + public ValueTask HandleAsync(ProcessAuthenticationContext context) + { + // Only access tokens are tenant-bound; skip other token types. + if (context.AccessTokenPrincipal is null || context.IsRejected) + { + return ValueTask.CompletedTask; + } + + var resolved = accessor.MultiTenantContext?.TenantInfo?.Identifier; + if (string.IsNullOrEmpty(resolved)) + { + // No resolved tenant on a token-bound request: refuse rather than guess. + context.Reject(Errors.InvalidToken, "No tenant was resolved for this request."); + return ValueTask.CompletedTask; + } + + var expected = TenantUrns.For(resolved); + var audiences = context.AccessTokenPrincipal.GetAudiences(); + if (!audiences.Contains(expected, StringComparer.Ordinal)) + { + context.Reject(Errors.InvalidToken, "Token audience does not match the resolved tenant."); + } + + return ValueTask.CompletedTask; + } +} diff --git a/spike/src/Idmt.Spike.Host/Bff/AuthCodeEndpoints.cs b/spike/src/Idmt.Spike.Host/Bff/AuthCodeEndpoints.cs new file mode 100644 index 0000000..4aed324 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Bff/AuthCodeEndpoints.cs @@ -0,0 +1,196 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Idmt.Spike.Host.Auth; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Idmt.Spike.Host.Bff; + +/// +/// Gate 8: real interactive browser login via authorization code + PKCE. The +/// authorization server hosts an interactive login session (the AuthServerLogin +/// cookie); the BFF drives the code flow, exchanges the code server-side, and +/// stores the resulting reference token in the same server-side session store the +/// raw bearer / gate-7 path uses. The browser only ever holds the opaque +/// bff_session cookie, and the token's subject is the authenticated user. +/// +public static class AuthServer +{ + public const string LoginScheme = "AuthServerLogin"; + + public static IServiceCollection AddAuthCodeFlow(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } + + public static void MapAuthCodeEndpoints(this WebApplication app) + { + // The authorization server's login page: establish the interactive session. + app.MapPost("/auth/login", async ( + AuthLoginRequest body, HttpContext ctx, UserManager users) => + { + var user = await users.FindByEmailAsync(body.Email); + if (user is null || !await users.CheckPasswordAsync(user, body.Password)) + { + return Results.Unauthorized(); + } + + var identity = new ClaimsIdentity(LoginScheme); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); + await ctx.SignInAsync(LoginScheme, new ClaimsPrincipal(identity)); + return Results.Ok(); + }); + + // Authorization endpoint (OpenIddict passthrough). Issues a code bound to + // the PKCE challenge for the interactively-authenticated user. + app.MapMethods("/connect/authorize", ["GET", "POST"], async (HttpContext ctx) => + { + var request = ctx.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("Not an OpenIddict authorization request."); + + var auth = await ctx.AuthenticateAsync(LoginScheme); + if (!auth.Succeeded || auth.Principal?.FindFirstValue(ClaimTypes.NameIdentifier) is not { } userId) + { + // No interactive session: a real AS would render a login page. The + // spike just refuses; the test logs in first. + return Results.Challenge(authenticationSchemes: [LoginScheme]); + } + + var identity = new ClaimsIdentity( + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, Claims.Name, Claims.Role); + identity.SetClaim(Claims.Subject, userId); + identity.SetScopes(request.GetScopes()); + + // Tenant audience from a custom 'tenant' parameter (the same convention + // the token endpoint uses), set directly so OpenIddict does not run its + // RFC 8707 'resource' validation against an unregistered URN. Carried + // into the code so the exchanged token is tenant-bound (gate 3 handler). + var tenant = (string?)request["tenant"]; + if (!string.IsNullOrEmpty(tenant)) + { + identity.SetAudiences(TenantUrns.For(tenant)); + } + + identity.SetDestinations(static _ => [Destinations.AccessToken]); + + return Results.SignIn( + new ClaimsPrincipal(identity), properties: null, + authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + }); + + // BFF initiation: generate PKCE, stash the verifier server-side, redirect + // the browser to the authorize endpoint. + // + // SPIKE LIMITATION (do not copy as-is): `state` is server-global and not + // bound to the initiating browser, so this is open to OAuth login-CSRF — + // any browser presenting a valid `state` at /bff/callback consumes the flow. + // PRODUCTION (v2): bind `state` to the browser — set a short-lived + // HttpOnly+Secure+SameSite=Lax `bff_oauth_state` cookie at initiation and, + // in /bff/callback, require a constant-time match against the inbound + // `state` (then clear the cookie) before consuming the flow. This gate + // proves the auth-code+PKCE *composition*, not a hardened BFF. + app.MapGet("/bff/login-pkce", (string tenant, IPkceFlowStore flows) => + { + var verifier = NewCodeVerifier(); + var challenge = S256Challenge(verifier); + var state = Guid.NewGuid().ToString("N"); + flows.Put(state, new PkceFlow(verifier, tenant)); + + var url = + "/connect/authorize" + + "?response_type=code" + + $"&client_id={IdmtSpikeSeeder.SpaClientId}" + + $"&redirect_uri={Uri.EscapeDataString(IdmtSpikeSeeder.SpaRedirectUri)}" + + "&scope=api" + + $"&tenant={Uri.EscapeDataString(tenant)}" + + $"&code_challenge={challenge}&code_challenge_method=S256" + + $"&state={state}"; + + return Results.Redirect(url); + }); + + // BFF callback: exchange the code (with the stored verifier) server-side, + // store the token in the session, set only the opaque session cookie. + app.MapGet("/bff/callback", async ( + string code, string state, + HttpContext ctx, + IPkceFlowStore flows, + IHttpClientFactory httpFactory, + IBffSessionStore sessions, + IDataProtectionProvider dp) => + { + var flow = flows.Take(state); + if (flow is null) + { + return Results.BadRequest("unknown state"); + } + + var client = httpFactory.CreateClient(BffBackChannel.Name); + var tokenResponse = await client.PostAsync("/connect/token", new FormUrlEncodedContent( + [ + new("grant_type", "authorization_code"), + new("code", code), + new("redirect_uri", IdmtSpikeSeeder.SpaRedirectUri), + new("client_id", IdmtSpikeSeeder.SpaClientId), + new("code_verifier", flow.Verifier), + ])); + + if (!tokenResponse.IsSuccessStatusCode) + { + var bodyText = await tokenResponse.Content.ReadAsStringAsync(); + return Results.Problem($"code exchange failed: {(int)tokenResponse.StatusCode} {bodyText}"); + } + + var payload = await tokenResponse.Content.ReadFromJsonAsync(); + // The user identity is carried by the token's subject; the BFF session + // need not know it, so userId stays empty here. + var sessionId = sessions.Create(Guid.Empty, flow.Tenant, payload!.AccessToken); + var protectedId = dp.CreateProtector(BffEndpoints.ProtectorPurpose).Protect(sessionId); + + BffEndpoints.AppendSessionCookie(ctx, protectedId); + return Results.Redirect("/"); + }); + } + + private static string NewCodeVerifier() => Base64Url(RandomNumberGenerator.GetBytes(32)); + + private static string S256Challenge(string verifier) => + Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public sealed record AuthLoginRequest(string Email, string Password); + + private sealed record TokenPayload( + [property: System.Text.Json.Serialization.JsonPropertyName("access_token")] string AccessToken); +} + +public sealed record PkceFlow(string Verifier, string Tenant); + +public interface IPkceFlowStore +{ + void Put(string state, PkceFlow flow); + PkceFlow? Take(string state); +} + +/// In-memory, single-use PKCE flow store keyed by the OAuth state value. +public sealed class InMemoryPkceFlowStore : IPkceFlowStore +{ + private readonly ConcurrentDictionary _flows = new(StringComparer.Ordinal); + + public void Put(string state, PkceFlow flow) => _flows[state] = flow; + + public PkceFlow? Take(string state) => _flows.TryRemove(state, out var flow) ? flow : null; +} diff --git a/spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs b/spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs new file mode 100644 index 0000000..1953849 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs @@ -0,0 +1,212 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using Idmt.Spike.Host.Auth; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.DataProtection; + +namespace Idmt.Spike.Host.Bff; + +/// +/// Gate 7: a backend-for-frontend session. The browser never receives a token — +/// it gets only an opaque, httpOnly session-id cookie. The host keeps the +/// reference token in a server-side session store and, on each request, resolves +/// the cookie to that token and replays it through the SAME OpenIddict validation +/// pipeline (including the tenant audience handler) a raw bearer request uses. +/// A mutating endpoint additionally requires an anti-forgery token. +/// +/// Stand-in scope (recorded for ADR §7.1): the session token is acquired by a +/// first-party client-credentials back-channel to the in-process token endpoint +/// (subject = client; user identity is carried by the server-side session, and +/// session revocation is by session deletion). A production BFF would complete +/// auth-code + PKCE and carry subject = user — deferred to §7.1. +/// +public static class BffEndpoints +{ + public const string CookieName = "bff_session"; + public const string ProtectorPurpose = "idmt.bff.session"; + + /// Sets the opaque, httpOnly BFF session cookie (the browser's only handle). + public static void AppendSessionCookie(HttpContext ctx, string protectedSessionId) => + ctx.Response.Cookies.Append(CookieName, protectedSessionId, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, // Strict would drop on the auth-code redirect-return. + Secure = false, // spike runs HTTP + IsEssential = true, + }); + + public static IServiceCollection AddBff(this IServiceCollection services) + { + services.AddAntiforgery(o => o.HeaderName = "X-CSRF-TOKEN"); + services.AddSingleton(); + // The self back-channel used to mint the session's reference token. Tests + // route this client's handler at the in-memory TestServer. + services.AddHttpClient(BffBackChannel.Name); + return services; + } + + /// + /// Resolver: if a session cookie is present and there is no Authorization + /// header, map the cookie to its server-side reference token and set the + /// bearer header. Must run before UseAuthentication. + /// + public static IApplicationBuilder UseBffSessionResolver(this IApplicationBuilder app) => + app.Use(async (ctx, next) => + { + if (!ctx.Request.Headers.ContainsKey("Authorization") && + ctx.Request.Cookies.TryGetValue(CookieName, out var protectedId)) + { + var protector = ctx.RequestServices + .GetRequiredService().CreateProtector(ProtectorPurpose); + var store = ctx.RequestServices.GetRequiredService(); + try + { + var session = store.Get(protector.Unprotect(protectedId)); + if (session is not null) + { + ctx.Request.Headers.Authorization = $"Bearer {session.ReferenceToken}"; + } + } + catch (System.Security.Cryptography.CryptographicException) + { + // Tampered/stale cookie: ignore, request proceeds unauthenticated. + } + } + + await next(); + }); + + public static void MapBffEndpoints(this WebApplication app) + { + // Login: validate password + TenantAccess gate, back-channel a reference + // token, store it server-side, set the opaque session cookie. Returns the + // anti-forgery request token but NO access token. + app.MapPost("/bff/login", async ( + LoginRequest body, + HttpContext ctx, + UserManager users, + ITenantAccessGate gate, + IHttpClientFactory httpFactory, + IBffSessionStore store, + IDataProtectionProvider dp) => + { + var user = await users.FindByEmailAsync(body.Email); + if (user is null || !await users.CheckPasswordAsync(user, body.Password)) + { + return Results.Unauthorized(); + } + + if (!await gate.CanAccessAsync(user.Id, body.Tenant, ctx.RequestAborted)) + { + return Results.Forbid(); + } + + var token = await BackChannelTokenAsync(httpFactory, body.Tenant, ctx.RequestAborted); + if (token is null) + { + return Results.Problem("Back-channel token acquisition failed."); + } + + var sessionId = store.Create(user.Id, body.Tenant, token); + var protectedId = dp.CreateProtector(ProtectorPurpose).Protect(sessionId); + AppendSessionCookie(ctx, protectedId); + + return Results.Ok(new LoginResponse()); + }); + + // Anti-forgery token issuance. Cookie-authed so the token binds to the same + // principal that /bff/widgets validates against (a real SPA fetches it the + // same way). Sets the anti-forgery cookie and returns the request token. + app.MapGet("/bff/csrf", (HttpContext ctx, IAntiforgery antiforgery) => + { + var tokens = antiforgery.GetAndStoreTokens(ctx); + return Results.Ok(new CsrfResponse(tokens.RequestToken!)); + }).RequireAuthorization(); + + // Mutating, cookie-authed endpoint guarded by anti-forgery. + app.MapPost("/bff/widgets", async ( + HttpContext ctx, + IAntiforgery antiforgery, + IdmtTenantDbContext db, + [FromQuery] string label) => + { + try + { + await antiforgery.ValidateRequestAsync(ctx); + } + catch (AntiforgeryValidationException) + { + return Results.BadRequest("missing or invalid anti-forgery token"); + } + + var widget = new TenantWidget { Label = label }; + db.Widgets.Add(widget); + await db.SaveChangesAsync(); + return Results.Ok(new { widget.Id, widget.TenantId }); + }).RequireAuthorization(); + } + + private static async Task BackChannelTokenAsync( + IHttpClientFactory httpFactory, string tenant, CancellationToken ct) + { + var client = httpFactory.CreateClient(BffBackChannel.Name); + var response = await client.PostAsync("/connect/token", new FormUrlEncodedContent( + [ + new("grant_type", "client_credentials"), + new("client_id", IdmtSpikeSeeder.ClientId), + new("client_secret", IdmtSpikeSeeder.ClientSecret), + new("scope", "api"), + new("tenant", tenant), + ]), ct); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var payload = await response.Content.ReadFromJsonAsync(ct); + return payload?.AccessToken; + } + + public sealed record LoginRequest(string Email, string Password, string Tenant); + public sealed record LoginResponse(); + public sealed record CsrfResponse(string AntiforgeryToken); + + private sealed record TokenPayload( + [property: System.Text.Json.Serialization.JsonPropertyName("access_token")] string AccessToken); +} + +/// The name of the self back-channel HttpClient (tests route it at the TestServer). +public static class BffBackChannel +{ + public const string Name = "bff-self"; +} + +public sealed record BffSession(Guid UserId, string Tenant, string ReferenceToken); + +public interface IBffSessionStore +{ + string Create(Guid userId, string tenant, string referenceToken); + BffSession? Get(string sessionId); +} + +/// In-memory session store. The reference token lives here, never in the browser. +public sealed class InMemoryBffSessionStore : IBffSessionStore +{ + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + + public string Create(Guid userId, string tenant, string referenceToken) + { + var sessionId = Guid.NewGuid().ToString("N"); + _sessions[sessionId] = new BffSession(userId, tenant, referenceToken); + return sessionId; + } + + public BffSession? Get(string sessionId) => + _sessions.TryGetValue(sessionId, out var session) ? session : null; +} diff --git a/spike/src/Idmt.Spike.Host/Domain/Domain.cs b/spike/src/Idmt.Spike.Host/Domain/Domain.cs new file mode 100644 index 0000000..ba4ae31 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Domain/Domain.cs @@ -0,0 +1,88 @@ +using Finbuckle.MultiTenant.Abstractions; +using Microsoft.AspNetCore.Identity; + +namespace Idmt.Spike.Host.Domain; + +/// Global system-role flag, mirrors v1 SysRoleKind. +public enum SysRoleKind +{ + None = 0, + SysAdmin = 1, + SysSupport = 2, +} + +/// Global canonical identity (one row per human). Mirrors v1 IdmtUser. +public class IdmtUser : IdentityUser +{ + public override Guid Id { get; set; } = Guid.CreateVersion7(); + public override string? SecurityStamp { get; set; } = Guid.NewGuid().ToString(); + public SysRoleKind SysRole { get; set; } = SysRoleKind.None; + public bool IsActive { get; set; } = true; +} + +/// Per-tenant role. Mirrors v1 IdmtRole. +public class IdmtRole : IdentityRole +{ + public IdmtRole() { } + public IdmtRole(string name) : base(name) { } + + public override Guid Id { get; set; } = Guid.CreateVersion7(); + public string TenantId { get; set; } = null!; +} + +/// +/// User-to-tenant edge. NOT multi-tenant: the issuance gate queries it by +/// (userId, tenantId) at the token endpoint where no ambient tenant exists. +/// +public sealed class TenantAccess +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public Guid UserId { get; set; } + public string TenantId { get; set; } = null!; + public bool IsActive { get; set; } = true; + public DateTimeOffset? ExpiresAt { get; set; } +} + +/// +/// Trivial multi-tenant entity used only to prove gate 4: Finbuckle stamps +/// TenantId on save under an ambient tenant, in the same database/connection +/// that hosts the (tenant-agnostic) OpenIddict stores. +/// +[MultiTenant] +public sealed class TenantWidget +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public string Label { get; set; } = null!; + public string TenantId { get; set; } = null!; +} + +/// +/// Support-impersonation audit row. Lives in the tenant-agnostic OpenIddict +/// DbContext so its write shares OpenIddict's store transaction (gate 2). +/// +public sealed class SupportAudit +{ + public Guid Id { get; set; } = Guid.CreateVersion7(); + public Guid ActorUserId { get; set; } + public string TenantId { get; set; } = null!; + public string Reason { get; set; } = null!; + public DateTimeOffset CreatedAt { get; set; } +} + +/// Tenant descriptor. Mirrors the v1 IdmtTenantInfo shape. +public record IdmtTenantInfo : ITenantInfo +{ + public IdmtTenantInfo() { } + + public IdmtTenantInfo(string identifier, string name) + { + Id = Guid.CreateVersion7().ToString(); + Identifier = identifier; + Name = name; + } + + public string Id { get; set; } = null!; + public string Identifier { get; set; } = null!; + public string? Name { get; set; } + public bool IsActive { get; set; } = true; +} diff --git a/spike/src/Idmt.Spike.Host/Idmt.Spike.Host.csproj b/spike/src/Idmt.Spike.Host/Idmt.Spike.Host.csproj new file mode 100644 index 0000000..77f4e82 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Idmt.Spike.Host.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/spike/src/Idmt.Spike.Host/Persistence/Contexts.cs b/spike/src/Idmt.Spike.Host/Persistence/Contexts.cs new file mode 100644 index 0000000..6c2d38b --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Persistence/Contexts.cs @@ -0,0 +1,57 @@ +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.EntityFrameworkCore; +using Finbuckle.MultiTenant.EntityFrameworkCore.Stores; +using Idmt.Spike.Host.Domain; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Idmt.Spike.Host.Persistence; + +/// +/// Global identity + the TenantAccess gate edge. Plain (non-multi-tenant): +/// users are global canonical rows, and the issuance gate must query +/// TenantAccess by (userId, tenantId) at the token endpoint where there is no +/// ambient tenant. Mirrors v1's de-tenanted IdmtUser reality. +/// +public sealed class IdmtIdentityDbContext(DbContextOptions options) + : IdentityDbContext(options) +{ + public DbSet TenantAccess => Set(); +} + +/// +/// Finbuckle multi-tenant app data. Holds only TenantWidget, whose TenantId is +/// stamped on save under the ambient tenant — the "Finbuckle stamps" half of +/// gate 4, proven to coexist with the tenant-agnostic OpenIddict stores. +/// +public sealed class IdmtTenantDbContext( + IMultiTenantContextAccessor accessor, + DbContextOptions options) + : MultiTenantDbContext(accessor, options) +{ + public DbSet Widgets => Set(); +} + +/// +/// Tenant-agnostic OpenIddict store. A PLAIN DbContext (never +/// MultiTenantDbContext), so Finbuckle never stamps or filters the OAuth tables. +/// Also hosts SupportAudit so a support-token insert and its audit row share one +/// context/transaction (gate 2). This is the heart of gate 4. +/// +public sealed class IdmtOpenIddictDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet SupportAudits => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.UseOpenIddict(); + } +} + +/// Finbuckle EFCore tenant-metadata store (the TenantInfo table). +public sealed class IdmtTenantStoreDbContext : EFCoreStoreDbContext +{ + public IdmtTenantStoreDbContext(DbContextOptions options) : base(options) { } +} diff --git a/spike/src/Idmt.Spike.Host/Program.cs b/spike/src/Idmt.Spike.Host/Program.cs new file mode 100644 index 0000000..0adf2ae --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Program.cs @@ -0,0 +1,107 @@ +using System.Security.Claims; +using Finbuckle.MultiTenant.AspNetCore.Extensions; +using Idmt.Spike.Host.Auth; +using Idmt.Spike.Host.Bff; +using Idmt.Spike.Host.Seeding; +using Idmt.Spike.Host.Server; +using Idmt.Spike.Host.Wiring; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddIdmtSpike(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +await IdmtSpikeSeeder.SeedAsync(app.Services); + +// Finbuckle must resolve the tenant BEFORE authentication so the audience +// handler can read the resolved tenant (gate 3). +app.UseMultiTenant(); +// Gate 7: resolve a BFF session cookie to its server-side reference token and set +// the bearer header BEFORE authentication, so the cookie path runs the exact same +// validation pipeline as a raw bearer request. +app.UseBffSessionResolver(); +app.UseAuthentication(); +app.UseAuthorization(); + +// Token endpoint: client-credentials passthrough. IDMT stamps the per-tenant +// audience from the request "tenant" parameter (gates 1, 3, 4). +app.MapPost("/connect/token", async (HttpContext ctx) => +{ + var request = ctx.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("Not an OpenIddict token request."); + + // Gate 8: authorization_code exchange. OpenIddict has already validated the + // code and its PKCE verifier; the stored principal (subject = user, audiences + // from authorize) is recovered and re-signed to issue the reference token. + if (request.IsAuthorizationCodeGrantType()) + { + var result = await ctx.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return Results.SignIn( + result.Principal!, + properties: null, + authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + if (!request.IsClientCredentialsGrantType()) + { + return Results.Forbid( + authenticationSchemes: [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]); + } + + var identity = new ClaimsIdentity( + OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + Claims.Name, + Claims.Role); + + identity.SetClaim(Claims.Subject, request.ClientId); + identity.SetScopes(request.GetScopes()); + + var tenant = (string?)request["tenant"]; + if (!string.IsNullOrEmpty(tenant)) + { + identity.SetAudiences(TenantUrns.For(tenant)); + } + + identity.SetDestinations(static _ => [Destinations.AccessToken]); + + return Results.SignIn( + new ClaimsPrincipal(identity), + properties: null, + authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); +}); + +// Gate 4 (stamping half): writing a [MultiTenant] entity under the ambient +// tenant gets its TenantId stamped by Finbuckle, in the same database that hosts +// the (tenant-agnostic) OpenIddict stores. Requires an X-Tenant header. +app.MapPost("/api/widgets", async (Idmt.Spike.Host.Persistence.IdmtTenantDbContext db, string label) => +{ + var widget = new Idmt.Spike.Host.Domain.TenantWidget { Label = label }; + db.Widgets.Add(widget); + await db.SaveChangesAsync(); + return Results.Ok(new { widget.Id, widget.TenantId }); +}); + +// Protected resource: requires a valid (non-revoked) reference token whose +// audience binds to the resolved tenant. +app.MapGet("/api/whoami", (ClaimsPrincipal user) => + Results.Ok(new + { + subject = user.GetClaim(Claims.Subject), + audiences = user.GetAudiences(), + })) + .RequireAuthorization(); + +app.MapBffEndpoints(); +app.MapAuthCodeEndpoints(); + +app.Run(); + +public partial class Program; diff --git a/spike/src/Idmt.Spike.Host/Properties/launchSettings.json b/spike/src/Idmt.Spike.Host/Properties/launchSettings.json new file mode 100644 index 0000000..d398d97 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5126", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7006;http://localhost:5126", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs b/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs new file mode 100644 index 0000000..00ff734 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs @@ -0,0 +1,122 @@ +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +using Microsoft.AspNetCore.Identity; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Idmt.Spike.Host.Seeding; + +/// Idempotent bring-up: schema, OpenIddict client + scopes, tenants, and a seeded sys admin. +public static class IdmtSpikeSeeder +{ + public const string ClientId = "spike-client"; + public const string ClientSecret = "spike-secret"; + + // Gate 8: a public SPA/BFF client that logs in via authorization code + PKCE. + public const string SpaClientId = "spike-spa"; + public const string SpaRedirectUri = "http://localhost/bff/callback"; + + public const string TenantA = "acme"; + public const string TenantB = "globex"; + + public const string SysAdminEmail = "sysadmin@example.com"; + + // A plain (non-system) user with TenantAccess to tenant A, used by the BFF + // login (gate 7) so it does not conflate with the sys-admin row. + public const string BffUserEmail = "bffuser@example.com"; + public const string BffUserPassword = "BffUser1!"; + + public static async Task SeedAsync(IServiceProvider sp, CancellationToken ct = default) + { + await using var scope = sp.CreateAsyncScope(); + var s = scope.ServiceProvider; + + await s.GetRequiredService().Database.EnsureCreatedAsync(ct); + await s.GetRequiredService().Database.EnsureCreatedAsync(ct); + await s.GetRequiredService().Database.EnsureCreatedAsync(ct); + await s.GetRequiredService().Database.EnsureCreatedAsync(ct); + + // Tenants + var store = s.GetRequiredService>(); + foreach (var id in new[] { TenantA, TenantB }) + { + if (await store.GetByIdentifierAsync(id) is null) + { + await store.AddAsync(new IdmtTenantInfo(id, id)); + } + } + + // OpenIddict client (client-credentials + token endpoint + scopes) + var apps = s.GetRequiredService(); + if (await apps.FindByClientIdAsync(ClientId, ct) is null) + { + await apps.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = ClientId, + ClientSecret = ClientSecret, + ClientType = ClientTypes.Confidential, + Permissions = + { + Permissions.Endpoints.Token, + Permissions.GrantTypes.ClientCredentials, + Permissions.Prefixes.Scope + "api", + Permissions.Prefixes.Scope + "support", + }, + }, ct); + } + + // Public PKCE SPA/BFF client (gate 8). + if (await apps.FindByClientIdAsync(SpaClientId, ct) is null) + { + await apps.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = SpaClientId, + ClientType = ClientTypes.Public, + RedirectUris = { new Uri(SpaRedirectUri) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.GrantTypes.RefreshToken, + Permissions.ResponseTypes.Code, + Permissions.Prefixes.Scope + "api", + }, + Requirements = + { + Requirements.Features.ProofKeyForCodeExchange, + }, + }, ct); + } + + // Sys admin user with TenantAccess to tenant A. + var users = s.GetRequiredService>(); + var idDb = s.GetRequiredService(); + var admin = await users.FindByEmailAsync(SysAdminEmail); + if (admin is null) + { + admin = new IdmtUser + { + UserName = SysAdminEmail, + Email = SysAdminEmail, + SysRole = SysRoleKind.SysAdmin, + }; + await users.CreateAsync(admin, "SysAdmin1!"); + + idDb.TenantAccess.Add(new TenantAccess { UserId = admin.Id, TenantId = TenantA }); + await idDb.SaveChangesAsync(ct); + } + + // Plain BFF user with TenantAccess to tenant A. + var bffUser = await users.FindByEmailAsync(BffUserEmail); + if (bffUser is null) + { + bffUser = new IdmtUser { UserName = BffUserEmail, Email = BffUserEmail }; + await users.CreateAsync(bffUser, BffUserPassword); + + idDb.TenantAccess.Add(new TenantAccess { UserId = bffUser.Id, TenantId = TenantA }); + await idDb.SaveChangesAsync(ct); + } + } +} diff --git a/spike/src/Idmt.Spike.Host/Server/SupportTokenService.cs b/spike/src/Idmt.Spike.Host/Server/SupportTokenService.cs new file mode 100644 index 0000000..c57d153 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Server/SupportTokenService.cs @@ -0,0 +1,101 @@ +using Idmt.Spike.Host.Auth; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +using Microsoft.EntityFrameworkCore; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Idmt.Spike.Host.Server; + +/// +/// Gate 2: mints a support token for a system user impersonating a tenant, and +/// writes the audit row in the SAME transaction as the OpenIddict token-store +/// insert. The TenantAccess gate re-runs first. +/// +/// Both writes go through one inside one +/// explicit transaction: the OpenIddict EF store resolves that same scoped +/// context, so its insert and the audit insert commit or roll back together. +/// This is the empirical answer to the ADR's flagged "unproven part" (uncertainty +/// #3): atomicity is achieved by minting through the token manager inside a +/// transaction we own — NOT through the deferred SignIn passthrough, whose token +/// creation runs after the request delegate returns, outside any handler-scoped +/// transaction. +/// +public sealed class SupportTokenService( + IOpenIddictTokenManager tokens, + IdmtOpenIddictDbContext oidb, + IdmtIdentityDbContext identity, + ITenantAccessGate gate, + TimeProvider clock) +{ + public sealed record Result(bool Allowed, string? TokenId, string? Denied); + + /// + /// Issues a support token. injects an audit-write + /// failure to prove the token does not survive a failed audit. + /// + public async Task IssueAsync( + Guid actorUserId, + string targetTenant, + string reason, + bool failAudit, + CancellationToken ct) + { + var actor = await identity.Users.FirstOrDefaultAsync(u => u.Id == actorUserId, ct); + if (actor is null || actor.SysRole == SysRoleKind.None) + { + return new Result(false, null, "not_a_system_user"); + } + + // Uniform TenantAccess gate re-runs at issuance for the exchange grant. + if (!await gate.CanAccessAsync(actorUserId, targetTenant, ct)) + { + return new Result(false, null, "no_tenant_access"); + } + + var now = clock.GetUtcNow(); + await using var tx = await oidb.Database.BeginTransactionAsync(ct); + + var descriptor = new OpenIddictTokenDescriptor + { + Subject = actorUserId.ToString(), + Type = "access_token", + Status = Statuses.Valid, + CreationDate = now, + ExpirationDate = now.AddMinutes(15), + ReferenceId = Guid.NewGuid().ToString("N"), + }; + + // CreateAsync persists the token entry to this same context inside the + // open transaction (the OpenIddict EF store resolves the same scoped + // IdmtOpenIddictDbContext instance), so the token is now written but + // uncommitted. + var token = await tokens.CreateAsync(descriptor, ct); + var tokenId = await tokens.GetIdAsync(token, ct); + + // Stage the audit row in the SAME context/transaction. When failAudit is + // set, Reason is null, which violates the NOT NULL column and makes the + // audit's SaveChanges fail at the database — AFTER the token was already + // persisted in this transaction. The transaction never commits, so the + // already-written token is rolled back: a real audit-write failure drops + // the token. + oidb.SupportAudits.Add(new SupportAudit + { + ActorUserId = actorUserId, + TenantId = targetTenant, + Reason = failAudit ? null! : reason, + CreatedAt = now, + }); + + await oidb.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + return new Result(true, tokenId, null); + } +} + +internal static class SupportProperties +{ + public const string Tenant = "idmt:support:tenant"; + public const string Actor = "idmt:support:actor"; +} diff --git a/spike/src/Idmt.Spike.Host/Server/TokenRevocationHook.cs b/spike/src/Idmt.Spike.Host/Server/TokenRevocationHook.cs new file mode 100644 index 0000000..8383cdb --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Server/TokenRevocationHook.cs @@ -0,0 +1,43 @@ +using OpenIddict.Abstractions; + +namespace Idmt.Spike.Host.Server; + +/// +/// Gate 6: the enforcement behind a SecurityStamp change. When a user's +/// credential changes (password, deactivation, compromise), every token they +/// hold must drop; when a single tenant's TenantAccess is revoked, only that +/// tenant's tokens drop. +/// +/// OpenIddict 7.5.0 exposes both as single store calls — +/// uses RevokeBySubjectAsync and uses +/// RevokeByAuthorizationIdAsync against the (subject, tenant) authorization +/// grouping established by . This is cleaner than the +/// enumerate-FindBySubjectAsync-and-TryRevokeAsync loop the ADR currently +/// describes, and it sidesteps mutating a live store enumeration on the shared +/// connection. (Recorded for the ADR §2.7 / §7.0 item-6 close-out.) +/// +public sealed class TokenRevocationHook( + IOpenIddictTokenManager tokens, + UserTokenMint mint) +{ + /// Drops every token the subject holds, across all tenants. Returns the count revoked. + public ValueTask RevokeAllForUserAsync(string subject, CancellationToken ct) => + tokens.RevokeBySubjectAsync(subject, ct); + + /// + /// Drops only the subject's tokens for one tenant, by revoking the + /// (subject, tenant) authorization. Returns false if the subject holds no + /// tokens for that tenant. + /// + public async Task RevokeForUserTenantAsync(string subject, string tenant, CancellationToken ct) + { + var authorizationId = await mint.FindTenantAuthorizationIdAsync(subject, tenant, ct); + if (authorizationId is null) + { + return false; + } + + await tokens.RevokeByAuthorizationIdAsync(authorizationId, ct); + return true; + } +} diff --git a/spike/src/Idmt.Spike.Host/Server/UserTokenMint.cs b/spike/src/Idmt.Spike.Host/Server/UserTokenMint.cs new file mode 100644 index 0000000..4cd354f --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Server/UserTokenMint.cs @@ -0,0 +1,94 @@ +using Idmt.Spike.Host.Auth; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Idmt.Spike.Host.Server; + +/// +/// Gate 6 prerequisite: mints user-subject reference tokens grouped under one +/// OpenIddict authorization per (subject, tenant). Grouping — not an audience +/// column — is how the single-tenant revoke is expressed, because a token entry +/// records no audience (the audience lives only in the encrypted payload). The +/// (subject, tenant) authorization carries a tenant marker scope so the hook can +/// find it again at revoke time. +/// +/// These tokens are created directly through the token manager, so they exist in +/// the store and are status-checkable, but they are NOT full bearer-validatable +/// reference tokens (no signed/encrypted payload). Gate 6 asserts revocation via +/// , not a bearer round-trip. +/// +public sealed class UserTokenMint( + IOpenIddictTokenManager tokens, + IOpenIddictAuthorizationManager authorizations, + TimeProvider clock) +{ + private const string TenantScopePrefix = "idmt:authz:tenant:"; + + private static string TenantScope(string tenant) => TenantScopePrefix + tenant; + + /// + /// Finds, or creates, the (subject, tenant) authorization and returns its id. + /// NOTE: this check-then-create is idempotent only sequentially. Concurrent + /// mints for the same (subject, tenant) could create duplicate authorizations, + /// which would make a later single-tenant revoke under-revoke. The spike is + /// single-threaded so the proof holds; the real implementation needs a + /// uniqueness guard or an upsert. + /// + public async Task EnsureTenantAuthorizationAsync(string subject, string tenant, CancellationToken ct) + { + var existing = await FindTenantAuthorizationIdAsync(subject, tenant, ct); + if (existing is not null) + { + return existing; + } + + var authorization = await authorizations.CreateAsync(new OpenIddictAuthorizationDescriptor + { + Subject = subject, + Status = Statuses.Valid, + Type = AuthorizationTypes.Permanent, + Scopes = { TenantScope(tenant) }, + }, ct); + + return (await authorizations.GetIdAsync(authorization, ct))!; + } + + /// Returns the (subject, tenant) authorization id, or null if none exists. + public async Task FindTenantAuthorizationIdAsync(string subject, string tenant, CancellationToken ct) + { + var marker = TenantScope(tenant); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, ct)) + { + var scopes = await authorizations.GetScopesAsync(authorization, ct); + if (scopes.Contains(marker, StringComparer.Ordinal)) + { + return await authorizations.GetIdAsync(authorization, ct); + } + } + + return null; + } + + /// + /// Mints one reference token for the subject, audienced to the tenant and + /// linked to the (subject, tenant) authorization. Returns the token id. + /// + public async Task MintAsync(string subject, string tenant, CancellationToken ct) + { + var authorizationId = await EnsureTenantAuthorizationAsync(subject, tenant, ct); + var now = clock.GetUtcNow(); + + var token = await tokens.CreateAsync(new OpenIddictTokenDescriptor + { + Subject = subject, + AuthorizationId = authorizationId, + Type = TokenTypeHints.AccessToken, + Status = Statuses.Valid, + CreationDate = now, + ExpirationDate = now.AddMinutes(15), + ReferenceId = Guid.NewGuid().ToString("N"), + }, ct); + + return (await tokens.GetIdAsync(token, ct))!; + } +} diff --git a/spike/src/Idmt.Spike.Host/Wiring/IdmtSelfCheckStartupFilter.cs b/spike/src/Idmt.Spike.Host/Wiring/IdmtSelfCheckStartupFilter.cs new file mode 100644 index 0000000..faae887 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Wiring/IdmtSelfCheckStartupFilter.cs @@ -0,0 +1,64 @@ +using Idmt.Spike.Host.Auth; +using Microsoft.Extensions.Options; +using OpenIddict.Server; +using OpenIddict.Validation; + +namespace Idmt.Spike.Host.Wiring; + +/// Thrown when a locked security invariant was subtracted from the configuration. +public sealed class IdmtSecurityInvariantException(string message) : InvalidOperationException(message); + +/// +/// Gate 5, layer 2 of the §2.9 seam. The last-wins post-configuration +/// () is the first line that re-applies locked options; +/// this startup filter is the back-stop that reads the FINAL options snapshot at +/// host start and fails fast if a consumer subtracted a locked property after the +/// lock (e.g. a raw PostConfigure registered after AddIdmtSpike). +/// +/// It proves detection of registration-expressed subtraction only; a consumer who +/// mutates options at resolve time (a custom +/// running after this snapshot is read) is out of reach, as the ADR §2.9 caveat +/// already concedes. +/// +public sealed class IdmtSelfCheckStartupFilter( + IOptions server, + IOptions validation) : IStartupFilter +{ + public Action Configure(Action next) + { + var s = server.Value; + var v = validation.Value; + + if (!s.UseReferenceAccessTokens) + { + throw new IdmtSecurityInvariantException( + "Locked invariant violated: UseReferenceAccessTokens must remain enabled."); + } + + if (s.DisableTokenStorage) + { + throw new IdmtSecurityInvariantException( + "Locked invariant violated: token storage must not be disabled."); + } + + if (s.EnableDegradedMode) + { + throw new IdmtSecurityInvariantException( + "Locked invariant violated: degraded mode must not be enabled."); + } + + if (!v.EnableTokenEntryValidation) + { + throw new IdmtSecurityInvariantException( + "Locked invariant violated: EnableTokenEntryValidation must remain enabled."); + } + + if (!v.Handlers.Any(d => d.ServiceDescriptor.ServiceType == typeof(TenantAudienceValidationHandler))) + { + throw new IdmtSecurityInvariantException( + "Locked invariant violated: the tenant audience validation handler must remain registered."); + } + + return next; + } +} diff --git a/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs b/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs new file mode 100644 index 0000000..faf4493 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs @@ -0,0 +1,144 @@ +using Finbuckle.MultiTenant.AspNetCore.Extensions; +using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using Finbuckle.MultiTenant.Extensions; +using Idmt.Spike.Host.Auth; +using Idmt.Spike.Host.Bff; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Idmt.Spike.Host.Wiring; + +/// Holds the per-context in-memory SQLite connections, kept open for the host lifetime. +public sealed class SpikeConnections : IDisposable +{ + public SqliteConnection Identity { get; } = Open(); + public SqliteConnection Tenant { get; } = Open(); + public SqliteConnection OpenIddict { get; } = Open(); + public SqliteConnection Store { get; } = Open(); + + private static SqliteConnection Open() + { + var c = new SqliteConnection("DataSource=:memory:"); + c.Open(); + return c; + } + + public void Dispose() + { + Identity.Dispose(); + Tenant.Dispose(); + OpenIddict.Dispose(); + Store.Dispose(); + } +} + +public static class SpikeWiring +{ + /// + /// The spike composition root. Wires the four contexts (each on its own + /// :memory: connection), Finbuckle, Identity, and OpenIddict (server + + /// local validation with reference tokens + token-entry validation), plus + /// the IDMT-owned gate and audience handler. + /// + public static IServiceCollection AddIdmtSpike(this IServiceCollection services) + { + var conns = new SpikeConnections(); + services.AddSingleton(conns); + services.AddSingleton(TimeProvider.System); + + services.AddDbContext(o => o.UseSqlite(conns.Identity)); + services.AddDbContext(o => o.UseSqlite(conns.Tenant)); + services.AddDbContext(o => + { + o.UseSqlite(conns.OpenIddict); + o.UseOpenIddict(); + }); + services.AddDbContext(o => o.UseSqlite(conns.Store)); + + services.AddMultiTenant() + .WithEFCoreStore() + .WithHeaderStrategy("X-Tenant") + .WithRouteStrategy("tenant", useTenantAmbientRouteValue: true); + + services.AddIdentityCore(o => o.User.RequireUniqueEmail = true) + .AddRoles() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + services.AddScoped(); + + services.AddOpenIddict() + .AddCore(o => o.UseEntityFrameworkCore().UseDbContext()) + .AddServer(o => + { + o.SetTokenEndpointUris("/connect/token"); + o.SetAuthorizationEndpointUris("/connect/authorize"); + + o.AllowClientCredentialsFlow(); + o.AllowRefreshTokenFlow(); + // Gate 8: real interactive browser login for the BFF. + o.AllowAuthorizationCodeFlow(); + // No public token-exchange grant: support tokens are minted + // server-side via the token manager so the audit write can share + // the token-store transaction (see SupportTokenService). The + // wire-level RFC 8693 grant defers token creation past the request + // handler, which would break that atomicity. + + o.RegisterScopes("api", "support"); + + // Reference (opaque) access tokens — the locked engine choice. + o.UseReferenceAccessTokens(); + o.DisableAccessTokenEncryption(); + + o.AddDevelopmentEncryptionCertificate(); + o.AddDevelopmentSigningCertificate(); + + o.UseAspNetCore() + .EnableTokenEndpointPassthrough() + .EnableAuthorizationEndpointPassthrough() + .DisableTransportSecurityRequirement(); // spike runs over HTTP + }) + .AddValidation(o => + { + o.UseLocalServer(); + // Per-request revocation: read the token entry every request. + o.EnableTokenEntryValidation(); + o.UseAspNetCore(); + // Gate 3: IDMT-owned per-request audience binding. + o.AddEventHandler(TenantAudienceValidationHandler.Descriptor); + }); + + services.AddScoped(); + + // §2.9 layer 1: last-registered post-configuration re-applies the locked + // options, so a customization that ran before this (e.g. through a builder + // hook) cannot subtract them — the lock runs later and wins. + services.PostConfigure(o => o.UseReferenceAccessTokens = true); + services.PostConfigure(o => o.EnableTokenEntryValidation = true); + + // §2.9 layer 2: a startup self-check fails host start if any locked + // invariant was subtracted after the lock. + services.AddTransient(); + + services.AddAuthentication(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme) + // Gate 8: the authorization server's own interactive login session, + // read by /connect/authorize. Distinct from bff_session and from the + // OpenIddict validation scheme (the API default). + .AddCookie(AuthServer.LoginScheme, o => + { + o.Cookie.HttpOnly = true; + o.Cookie.SameSite = SameSiteMode.Lax; + o.Cookie.Name = "as_login"; + }); + services.AddAuthorization(); + + services.AddBff(); + services.AddAuthCodeFlow(); + + return services; + } +} diff --git a/spike/src/Idmt.Spike.Host/appsettings.Development.json b/spike/src/Idmt.Spike.Host/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/spike/src/Idmt.Spike.Host/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/spike/src/Idmt.Spike.Host/appsettings.json b/spike/src/Idmt.Spike.Host/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/spike/tests/Idmt.Spike.Tests/BaseSpikeIntegrationTest.cs b/spike/tests/Idmt.Spike.Tests/BaseSpikeIntegrationTest.cs new file mode 100644 index 0000000..df5108f --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/BaseSpikeIntegrationTest.cs @@ -0,0 +1,53 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Idmt.Spike.Tests; + +/// Spins up the spike host (fresh in-memory SQLite + seed per factory). +public abstract class BaseSpikeIntegrationTest : IClassFixture> +{ + protected WebApplicationFactory Factory { get; } + + protected BaseSpikeIntegrationTest(WebApplicationFactory factory) => Factory = factory; + + /// Requests a client-credentials reference token audienced for the given tenant. + protected async Task GetClientTokenAsync(string tenant, string scope = "api") + { + var client = Factory.CreateClient(); + var response = await client.PostAsync("/connect/token", new FormUrlEncodedContent( + [ + new("grant_type", "client_credentials"), + new("client_id", IdmtSpikeSeeder.ClientId), + new("client_secret", IdmtSpikeSeeder.ClientSecret), + new("scope", scope), + new("tenant", tenant), + ])); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Token request failed: {(int)response.StatusCode} {body}"); + } + + var payload = await response.Content.ReadFromJsonAsync(); + return payload!.AccessToken; + } + + /// A client whose requests resolve to via the X-Tenant header. + protected HttpClient ClientForTenant(string tenant, string? bearer = null) + { + var client = Factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Tenant", tenant); + if (bearer is not null) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + } + return client; + } + + protected sealed record TokenResponse( + [property: System.Text.Json.Serialization.JsonPropertyName("access_token")] string AccessToken, + [property: System.Text.Json.Serialization.JsonPropertyName("token_type")] string TokenType); +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate1_ReferenceTokenRevocationTests.cs b/spike/tests/Idmt.Spike.Tests/Gate1_ReferenceTokenRevocationTests.cs new file mode 100644 index 0000000..56113b2 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate1_ReferenceTokenRevocationTests.cs @@ -0,0 +1,40 @@ +using System.Net; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 1: reference tokens with EnableTokenEntryValidation revoke on the next +/// request through the local validation handler. +/// +public sealed class Gate1_ReferenceTokenRevocationTests(WebApplicationFactory factory) + : BaseSpikeIntegrationTest(factory) +{ + [Fact] + public async Task RevokedReferenceToken_Returns401_OnNextRequest() + { + var token = await GetClientTokenAsync(IdmtSpikeSeeder.TenantA); + var client = ClientForTenant(IdmtSpikeSeeder.TenantA, token); + + // Valid before revocation. + var before = await client.GetAsync("/api/whoami"); + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + + // Revoke the token entry server-side (single row status update). + using (var scope = Factory.Services.CreateScope()) + { + var manager = scope.ServiceProvider.GetRequiredService(); + await foreach (var entry in manager.FindBySubjectAsync(IdmtSpikeSeeder.ClientId)) + { + await manager.TryRevokeAsync(entry); + } + } + + // Rejected on the next request, before the token's TTL expires. + var after = await client.GetAsync("/api/whoami"); + Assert.Equal(HttpStatusCode.Unauthorized, after.StatusCode); + } +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate2_TokenExchangeAuditAtomicityTests.cs b/spike/tests/Idmt.Spike.Tests/Gate2_TokenExchangeAuditAtomicityTests.cs new file mode 100644 index 0000000..6362b81 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate2_TokenExchangeAuditAtomicityTests.cs @@ -0,0 +1,91 @@ +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +using Idmt.Spike.Host.Seeding; +using Idmt.Spike.Host.Server; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 2: the support-token mint runs the TenantAccess gate and writes its audit +/// row in the SAME transaction as the OpenIddict token-store insert. A failed +/// audit leaves neither a token nor an audit row. +/// +public sealed class Gate2_TokenExchangeAuditAtomicityTests(WebApplicationFactory factory) + : BaseSpikeIntegrationTest(factory) +{ + [Fact] + public async Task Success_WritesTokenAndAudit_TogetherAfterGate() + { + var adminId = await AdminIdAsync(); + var (tokens, audits) = await CountsAsync(); + + using (var scope = Factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var result = await svc.IssueAsync(adminId, IdmtSpikeSeeder.TenantA, "investigating ticket 42", failAudit: false, default); + Assert.True(result.Allowed); + } + + var (tokensAfter, auditsAfter) = await CountsAsync(); + Assert.Equal(tokens + 1, tokensAfter); + Assert.Equal(audits + 1, auditsAfter); + } + + [Fact] + public async Task AuditFailure_RollsBack_AlreadyPersistedToken() + { + var adminId = await AdminIdAsync(); + var (tokens, audits) = await CountsAsync(); + + // The token is persisted by CreateAsync inside the transaction; the audit + // write then fails at the database (NOT NULL violation). Because the + // transaction never commits, the already-written token is rolled back. + using (var scope = Factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + await Assert.ThrowsAnyAsync(() => + svc.IssueAsync(adminId, IdmtSpikeSeeder.TenantA, "boom", failAudit: true, default)); + } + + // Read committed state from a FRESH scope: neither the token nor the audit survived. + var (tokensAfter, auditsAfter) = await CountsAsync(); + Assert.Equal(tokens, tokensAfter); + Assert.Equal(audits, auditsAfter); + } + + [Fact] + public async Task Gate_DeniesTenant_WithoutAccess() + { + var adminId = await AdminIdAsync(); + + using var scope = Factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + + // The seeded admin has access to tenant A only. + var result = await svc.IssueAsync(adminId, IdmtSpikeSeeder.TenantB, "no access", failAudit: false, default); + + Assert.False(result.Allowed); + Assert.Equal("no_tenant_access", result.Denied); + } + + private async Task AdminIdAsync() + { + using var scope = Factory.Services.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var admin = await users.FindByEmailAsync(IdmtSpikeSeeder.SysAdminEmail); + return admin!.Id; + } + + private async Task<(long Tokens, int Audits)> CountsAsync() + { + using var scope = Factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var oidb = scope.ServiceProvider.GetRequiredService(); + return (await manager.CountAsync(), await oidb.SupportAudits.CountAsync()); + } +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate3_AudienceHandlerTests.cs b/spike/tests/Idmt.Spike.Tests/Gate3_AudienceHandlerTests.cs new file mode 100644 index 0000000..053eea0 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate3_AudienceHandlerTests.cs @@ -0,0 +1,36 @@ +using System.Net; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 3: the IDMT-owned per-request audience handler rejects a token whose +/// audience does not equal the Finbuckle-resolved tenant. +/// +public sealed class Gate3_AudienceHandlerTests(WebApplicationFactory factory) + : BaseSpikeIntegrationTest(factory) +{ + [Fact] + public async Task TokenForTenantA_OnTenantBRoute_IsRejected() + { + var tokenForA = await GetClientTokenAsync(IdmtSpikeSeeder.TenantA); + + // Same token, but the request resolves to tenant B. + var crossTenant = ClientForTenant(IdmtSpikeSeeder.TenantB, tokenForA); + var response = await crossTenant.GetAsync("/api/whoami"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task TokenForTenantA_OnTenantARoute_IsAccepted() + { + var tokenForA = await GetClientTokenAsync(IdmtSpikeSeeder.TenantA); + + var sameTenant = ClientForTenant(IdmtSpikeSeeder.TenantA, tokenForA); + var response = await sameTenant.GetAsync("/api/whoami"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate4_DualContextCompositionTests.cs b/spike/tests/Idmt.Spike.Tests/Gate4_DualContextCompositionTests.cs new file mode 100644 index 0000000..05c0a29 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate4_DualContextCompositionTests.cs @@ -0,0 +1,58 @@ +using System.Net.Http.Json; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 4 (the ADR's first item): OpenIddict's EF stores in a separate, +/// tenant-agnostic DbContext coexist with Finbuckle save-side TenantId stamping, +/// and the token endpoint reads/writes tokens with no ambient tenant. +/// +public sealed class Gate4_DualContextCompositionTests(WebApplicationFactory factory) + : BaseSpikeIntegrationTest(factory) +{ + [Fact] + public async Task TokenEndpoint_IssuesAndPersistsToken_WithNoAmbientTenant() + { + // The token request carries NO X-Tenant header: no ambient tenant. + var token = await GetClientTokenAsync(IdmtSpikeSeeder.TenantA); + Assert.False(string.IsNullOrWhiteSpace(token)); + + // The reference token was persisted to the tenant-agnostic OpenIddict store. + using var scope = Factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var count = await manager.CountAsync(); + Assert.True(count > 0, "Expected at least one persisted OpenIddict token entry."); + } + + [Fact] + public async Task FinbuckleStampsAppEntity_WhileOpenIddictTablesStayTenantAgnostic() + { + // Finbuckle stamps the app entity's TenantId (the tenant's Id) on save + // under the ambient tenant. + var client = ClientForTenant(IdmtSpikeSeeder.TenantA); + var response = await client.PostAsync($"/api/widgets?label=alpha", content: null); + response.EnsureSuccessStatusCode(); + var widget = await response.Content.ReadFromJsonAsync(); + + using var scope = Factory.Services.CreateScope(); + var store = scope.ServiceProvider + .GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtSpikeSeeder.TenantA); + Assert.False(string.IsNullOrEmpty(widget!.TenantId)); + Assert.Equal(tenant!.Id, widget.TenantId); + + // The OpenIddict token table has no TenantId concept: its model has no such property. + var oidb = scope.ServiceProvider.GetRequiredService(); + var tokenEntityType = oidb.Model.GetEntityTypes() + .Single(t => t.ClrType.Name.StartsWith("OpenIddictEntityFrameworkCoreToken", StringComparison.Ordinal)); + Assert.Null(tokenEntityType.FindProperty("TenantId")); + } + + private sealed record WidgetDto(Guid Id, string TenantId); +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate5_SelfCheckTests.cs b/spike/tests/Idmt.Spike.Tests/Gate5_SelfCheckTests.cs new file mode 100644 index 0000000..0666e07 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate5_SelfCheckTests.cs @@ -0,0 +1,71 @@ +using Idmt.Spike.Host.Wiring; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenIddict.Server; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 5: the two-layer §2.9 seam. Layer 1 — a last-wins post-configuration — +/// re-clamps a locked option a consumer subtracted before it. Layer 2 — a startup +/// self-check — fails host start when a consumer subtracts a locked option after +/// the lock (the position a raw consumer PostConfigure lands). +/// +public sealed class Gate5_SelfCheckTests(WebApplicationFactory factory) + : BaseSpikeIntegrationTest(factory) +{ + [Fact] + public void Layer1_LastWinsLock_ReclampsEarlierSubtraction() + { + // A hostile subtraction registered BEFORE AddIdmtSpike: the lock runs later + // and wins, so the final snapshot still has reference tokens enabled. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddRouting(); + services.AddDataProtection(); + services.PostConfigure(o => o.UseReferenceAccessTokens = false); + + services.AddIdmtSpike(); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.True(options.UseReferenceAccessTokens, + "Layer-1 lock should re-clamp UseReferenceAccessTokens after an earlier subtraction."); + } + + [Fact] + public void HealthyHost_Boots() + { + // The unmodified host starts and serves (control for the hostile case below). + using var client = Factory.CreateClient(); + Assert.NotNull(client); + } + + [Fact] + public void Layer2_SelfCheck_FailsHostStart_OnLaterSubtraction() + { + // A hostile subtraction registered AFTER AddIdmtSpike (the position a raw + // consumer PostConfigure lands) is past the lock's reach, so the startup + // self-check must fail host start. + using var hostile = Factory.WithWebHostBuilder(builder => + builder.ConfigureTestServices(services => + services.PostConfigure(o => o.UseReferenceAccessTokens = false))); + + var ex = Assert.ThrowsAny(() => hostile.CreateClient()); + + var invariant = Unwrap(ex).OfType().FirstOrDefault(); + Assert.NotNull(invariant); + Assert.Contains("UseReferenceAccessTokens", invariant!.Message, StringComparison.Ordinal); + } + + private static IEnumerable Unwrap(Exception ex) + { + for (var current = ex; current is not null; current = current.InnerException) + { + yield return current; + } + } +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate6_SecurityStampRevocationTests.cs b/spike/tests/Idmt.Spike.Tests/Gate6_SecurityStampRevocationTests.cs new file mode 100644 index 0000000..f62a5d2 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate6_SecurityStampRevocationTests.cs @@ -0,0 +1,135 @@ +using Idmt.Spike.Host.Seeding; +using Idmt.Spike.Host.Server; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 6: the SecurityStamp-change revocation hook. All-user revoke drops every +/// token the user holds; single-tenant revoke drops only that tenant's tokens — +/// expressed by per-(subject, tenant) authorization grouping, since a token entry +/// records no audience to filter on. Tokens are minted directly through the token +/// manager (status-checkable, not bearer-validatable), so the assertions read +/// GetStatusAsync rather than round-tripping a bearer request. +/// +public sealed class Gate6_SecurityStampRevocationTests(WebApplicationFactory factory) + : BaseSpikeIntegrationTest(factory) +{ + [Fact] + public async Task RevokeAllForUser_DropsEveryTokenAcrossTenants_AtBoundedCost() + { + // A fresh synthetic subject keeps this test independent of the others. + var subject = Guid.NewGuid().ToString(); + + // "Many tokens": 60 for acme, 40 for globex. + await MintAsync(subject, IdmtSpikeSeeder.TenantA, 60); + await MintAsync(subject, IdmtSpikeSeeder.TenantB, 40); + Assert.Equal(100, await CountAsync(subject)); + + using (var scope = Factory.Services.CreateScope()) + { + var hook = scope.ServiceProvider.GetRequiredService(); + // RevokeBySubjectAsync is a single store call: cost does not scale with + // the number of tokens the user holds (the property item 6 names). + var revoked = await hook.RevokeAllForUserAsync(subject, default); + Assert.Equal(100, revoked); + } + + var statuses = await StatusesAsync(subject); + Assert.Equal(100, statuses.Count); + Assert.All(statuses, s => Assert.Equal(Statuses.Revoked, s, ignoreCase: true)); + } + + [Fact] + public async Task RevokeForUserTenant_DropsOnlyThatTenant() + { + var subject = Guid.NewGuid().ToString(); + await MintAsync(subject, IdmtSpikeSeeder.TenantA, 5); + await MintAsync(subject, IdmtSpikeSeeder.TenantB, 5); + + using (var scope = Factory.Services.CreateScope()) + { + var hook = scope.ServiceProvider.GetRequiredService(); + var revoked = await hook.RevokeForUserTenantAsync(subject, IdmtSpikeSeeder.TenantA, default); + Assert.True(revoked); + } + + var byTenant = await StatusesByTenantAsync(subject); + Assert.All(byTenant[IdmtSpikeSeeder.TenantA], s => Assert.Equal(Statuses.Revoked, s, ignoreCase: true)); + Assert.All(byTenant[IdmtSpikeSeeder.TenantB], s => Assert.Equal(Statuses.Valid, s, ignoreCase: true)); + } + + private async Task MintAsync(string subject, string tenant, int count) + { + using var scope = Factory.Services.CreateScope(); + var mint = scope.ServiceProvider.GetRequiredService(); + for (var i = 0; i < count; i++) + { + await mint.MintAsync(subject, tenant, default); + } + } + + private async Task CountAsync(string subject) + { + using var scope = Factory.Services.CreateScope(); + var tokens = scope.ServiceProvider.GetRequiredService(); + var n = 0; + await foreach (var _ in tokens.FindBySubjectAsync(subject, default)) + { + n++; + } + + return n; + } + + private async Task> StatusesAsync(string subject) + { + using var scope = Factory.Services.CreateScope(); + var tokens = scope.ServiceProvider.GetRequiredService(); + var statuses = new List(); + await foreach (var token in tokens.FindBySubjectAsync(subject, default)) + { + statuses.Add((await tokens.GetStatusAsync(token, default))!); + } + + return statuses; + } + + private async Task>> StatusesByTenantAsync(string subject) + { + using var scope = Factory.Services.CreateScope(); + var tokens = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + + // Map each (subject, tenant) authorization id back to its tenant via the marker scope. + var tenantByAuthId = new Dictionary(StringComparer.Ordinal); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, default)) + { + var id = (await authorizations.GetIdAsync(authorization, default))!; + var scopes = await authorizations.GetScopesAsync(authorization, default); + var marker = scopes.FirstOrDefault(s => s.StartsWith("idmt:authz:tenant:", StringComparison.Ordinal)); + if (marker is not null) + { + tenantByAuthId[id] = marker["idmt:authz:tenant:".Length..]; + } + } + + var result = new Dictionary>(StringComparer.Ordinal); + await foreach (var token in tokens.FindBySubjectAsync(subject, default)) + { + var authId = await tokens.GetAuthorizationIdAsync(token, default); + if (authId is null || !tenantByAuthId.TryGetValue(authId, out var tenant)) + { + continue; + } + + var status = (await tokens.GetStatusAsync(token, default))!; + (result.TryGetValue(tenant, out var list) ? list : result[tenant] = []).Add(status); + } + + return result; + } +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate7_BffSessionTests.cs b/spike/tests/Idmt.Spike.Tests/Gate7_BffSessionTests.cs new file mode 100644 index 0000000..338402a --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate7_BffSessionTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Http.Json; +using Idmt.Spike.Host.Bff; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 7: a BFF session. The browser holds only an opaque session-id cookie; the +/// host resolves it server-side to a reference token and runs it through the SAME +/// audience handler a raw bearer request runs. A mutating request without an +/// anti-forgery token is rejected. +/// +public sealed class Gate7_BffSessionTests(Gate7Factory factory) : IClassFixture +{ + private readonly Gate7Factory _factory = factory; + + [Fact] + public async Task Login_SetsHttpOnlyCookie_AndKeepsTokenServerSideOnly() + { + var client = _factory.CreateClient(); + var response = await LoginAsync(client, IdmtSpikeSeeder.TenantA); + response.EnsureSuccessStatusCode(); + + // The browser-facing response carries no access/refresh token. + var body = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("access_token", body, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("refresh_token", body, StringComparison.OrdinalIgnoreCase); + + // The session cookie is httpOnly and is NOT the token: the token lives only + // in the server-side session store. + var setCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"), + c => c.StartsWith(BffEndpoints.CookieName + "=", StringComparison.Ordinal)); + Assert.Contains("httponly", setCookie, StringComparison.OrdinalIgnoreCase); + + var cookieValue = setCookie.Split(';')[0][(BffEndpoints.CookieName.Length + 1)..]; + + // Decode the cookie to the session id, then read the server-side session: + // the cookie is a protected session id (GUID "N"), and the actual reference + // token lives only in the store — it appears in neither the cookie nor the + // response body. This is the real "no token in the browser" proof. + var protector = _factory.Services + .GetRequiredService().CreateProtector(BffEndpoints.ProtectorPurpose); + var sessionId = protector.Unprotect(cookieValue); + var session = _factory.Services.GetRequiredService().Get(sessionId); + + Assert.NotNull(session); + Assert.False(string.IsNullOrEmpty(session!.ReferenceToken)); + Assert.NotEqual(session.ReferenceToken, cookieValue); + Assert.DoesNotContain(session.ReferenceToken, cookieValue, StringComparison.Ordinal); + Assert.DoesNotContain(session.ReferenceToken, body, StringComparison.Ordinal); + } + + [Fact] + public async Task CookieRequest_RunsSameAudienceHandler_AcceptsHomeTenant_RejectsOther() + { + var client = _factory.CreateClient(); + var login = await LoginAsync(client, IdmtSpikeSeeder.TenantA); + login.EnsureSuccessStatusCode(); + + // Same client (shared cookie container) carries the bff_session cookie. + // acme-resolved request: the session token's audience matches -> 200. + var ok = await client.SendAsync(WhoAmI(IdmtSpikeSeeder.TenantA)); + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + + // globex-resolved request: same cookie, same validation, audience mismatch + // -> 401 from the very handler a raw bearer request hits (gate 3). + var rejected = await client.SendAsync(WhoAmI(IdmtSpikeSeeder.TenantB)); + Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode); + } + + [Fact] + public async Task MutatingRequest_WithoutAntiforgeryToken_IsRejected_WithToken_Succeeds() + { + var client = _factory.CreateClient(); + var login = await LoginAsync(client, IdmtSpikeSeeder.TenantA); + login.EnsureSuccessStatusCode(); + var csrf = await GetCsrfAsync(client); + + // Cookie present (auth passes via the resolver) but NO anti-forgery token. + var missing = new HttpRequestMessage(HttpMethod.Post, "/bff/widgets?label=alpha"); + missing.Headers.Add("X-Tenant", IdmtSpikeSeeder.TenantA); + var missingResponse = await client.SendAsync(missing); + Assert.Equal(HttpStatusCode.BadRequest, missingResponse.StatusCode); + + // Same client, now echoing the anti-forgery request token -> success. + var valid = new HttpRequestMessage(HttpMethod.Post, "/bff/widgets?label=beta"); + valid.Headers.Add("X-Tenant", IdmtSpikeSeeder.TenantA); + valid.Headers.Add("X-CSRF-TOKEN", csrf); + var validResponse = await client.SendAsync(valid); + validResponse.EnsureSuccessStatusCode(); + } + + private static Task LoginAsync(HttpClient client, string tenant) => + client.PostAsJsonAsync("/bff/login", + new BffEndpoints.LoginRequest(IdmtSpikeSeeder.BffUserEmail, IdmtSpikeSeeder.BffUserPassword, tenant)); + + private static async Task GetCsrfAsync(HttpClient client) + { + // Carries X-Tenant so the session token resolves and authenticates (the + // audience handler refuses a token-bound request with no resolved tenant). + var request = new HttpRequestMessage(HttpMethod.Get, "/bff/csrf"); + request.Headers.Add("X-Tenant", IdmtSpikeSeeder.TenantA); + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!.AntiforgeryToken; + } + + private static HttpRequestMessage WhoAmI(string tenant) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/whoami"); + request.Headers.Add("X-Tenant", tenant); + return request; + } +} + +/// +/// Routes the BFF self back-channel HttpClient at the in-memory TestServer, so +/// /bff/login can mint a real reference token from the co-hosted token +/// endpoint without leaving the process. +/// +public sealed class Gate7Factory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) => + builder.ConfigureTestServices(services => + services.AddHttpClient(BffBackChannel.Name) + .ConfigurePrimaryHttpMessageHandler(() => Server.CreateHandler()) + .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://localhost"))); +} diff --git a/spike/tests/Idmt.Spike.Tests/Gate8_AuthCodePkceTests.cs b/spike/tests/Idmt.Spike.Tests/Gate8_AuthCodePkceTests.cs new file mode 100644 index 0000000..94c6a43 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Gate8_AuthCodePkceTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Net.Http.Json; +using Idmt.Spike.Host.Bff; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Seeding; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Idmt.Spike.Tests; + +/// +/// Gate 8: real browser login via authorization code + PKCE. The BFF drives the +/// flow, exchanges the code server-side, and stores the reference token in the +/// session — the browser holds only the opaque session cookie. The issued token's +/// subject is the authenticated USER (not the client), and it resolves through the +/// same tenant audience handler a raw bearer request uses. PKCE is enforced. +/// +public sealed class Gate8_AuthCodePkceTests(Gate7Factory factory) : IClassFixture +{ + private readonly Gate7Factory _factory = factory; + + private HttpClient NoRedirectClient() => + _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + [Fact] + public async Task AuthCodePkce_IssuesUserSubjectToken_AndResolvesSession() + { + var client = NoRedirectClient(); + var userId = await UserIdAsync(); + + await LoginAsync(client); + await RunPkceFlowAsync(client, IdmtSpikeSeeder.TenantA); + + // The BFF session (bff_session cookie) resolves to the server-side token, + // which validates through the OpenIddict pipeline + audience handler. + var whoami = await client.SendAsync(WhoAmI(IdmtSpikeSeeder.TenantA)); + Assert.Equal(HttpStatusCode.OK, whoami.StatusCode); + + var body = await whoami.Content.ReadFromJsonAsync(); + // The core proof: subject is the authenticated user, not the client. + Assert.Equal(userId.ToString(), body!.Subject); + Assert.NotEqual(IdmtSpikeSeeder.SpaClientId, body.Subject); + } + + [Fact] + public async Task BffSession_RunsSameAudienceHandler_RejectsOtherTenant() + { + var client = NoRedirectClient(); + await LoginAsync(client); + await RunPkceFlowAsync(client, IdmtSpikeSeeder.TenantA); + + var acme = await client.SendAsync(WhoAmI(IdmtSpikeSeeder.TenantA)); + Assert.Equal(HttpStatusCode.OK, acme.StatusCode); + + var globex = await client.SendAsync(WhoAmI(IdmtSpikeSeeder.TenantB)); + Assert.Equal(HttpStatusCode.Unauthorized, globex.StatusCode); + } + + [Fact] + public async Task Authorize_WithoutPkceChallenge_IsRejected() + { + var client = NoRedirectClient(); + await LoginAsync(client); + + // A direct authorize request with no code_challenge: the public client + // requires PKCE, so OpenIddict must refuse to issue a code. + var url = + "/connect/authorize?response_type=code" + + $"&client_id={IdmtSpikeSeeder.SpaClientId}" + + $"&redirect_uri={Uri.EscapeDataString(IdmtSpikeSeeder.SpaRedirectUri)}" + + "&scope=api&state=nopkce"; + var response = await client.GetAsync(url); + + var location = response.Headers.Location?.ToString() ?? string.Empty; + Assert.DoesNotContain("code=", location, StringComparison.Ordinal); + Assert.True( + response.StatusCode == HttpStatusCode.BadRequest || location.Contains("error", StringComparison.Ordinal), + $"Expected a PKCE rejection, got {(int)response.StatusCode} location='{location}'."); + } + + // Drives /bff/login-pkce -> /connect/authorize -> /bff/callback, leaving the + // bff_session cookie on the shared client. Asserts no token ever reaches the + // browser (the redirect chain carries only code/state, never an access token). + private static async Task RunPkceFlowAsync(HttpClient client, string tenant) + { + var start = await client.GetAsync($"/bff/login-pkce?tenant={tenant}"); + Assert.Equal(HttpStatusCode.Redirect, start.StatusCode); + var authorizeUrl = start.Headers.Location!.ToString(); + Assert.Contains("code_challenge=", authorizeUrl, StringComparison.Ordinal); + + var authorize = await client.GetAsync(authorizeUrl); + Assert.True(authorize.StatusCode == HttpStatusCode.Redirect, + $"authorize -> {(int)authorize.StatusCode}: {await authorize.Content.ReadAsStringAsync()} (url={authorizeUrl})"); + var callbackUrl = authorize.Headers.Location!.ToString(); + Assert.Contains("code=", callbackUrl, StringComparison.Ordinal); + + var callback = await client.GetAsync(callbackUrl); + Assert.Equal(HttpStatusCode.Redirect, callback.StatusCode); + // The callback sets the session cookie and redirects to "/", carrying no token. + Assert.DoesNotContain("access_token", callbackUrl, StringComparison.OrdinalIgnoreCase); + var setCookie = string.Join(";", callback.Headers.TryGetValues("Set-Cookie", out var v) ? v : []); + Assert.Contains(BffEndpoints.CookieName, setCookie, StringComparison.Ordinal); + } + + private static async Task LoginAsync(HttpClient client) + { + var response = await client.PostAsJsonAsync("/auth/login", + new AuthServer.AuthLoginRequest(IdmtSpikeSeeder.BffUserEmail, IdmtSpikeSeeder.BffUserPassword)); + response.EnsureSuccessStatusCode(); + } + + private async Task UserIdAsync() + { + using var scope = _factory.Services.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var user = await users.FindByEmailAsync(IdmtSpikeSeeder.BffUserEmail); + return user!.Id; + } + + private static HttpRequestMessage WhoAmI(string tenant) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/whoami"); + request.Headers.Add("X-Tenant", tenant); + return request; + } + + private sealed record WhoAmIResponse(string Subject, string[] Audiences); +} diff --git a/spike/tests/Idmt.Spike.Tests/Idmt.Spike.Tests.csproj b/spike/tests/Idmt.Spike.Tests/Idmt.Spike.Tests.csproj new file mode 100644 index 0000000..88c3df6 --- /dev/null +++ b/spike/tests/Idmt.Spike.Tests/Idmt.Spike.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Idmt.BasicSample.Tests/Admin/CreateTenantInvokerAccessTests.cs b/tests/Idmt.BasicSample.Tests/Admin/CreateTenantInvokerAccessTests.cs new file mode 100644 index 0000000..eaf9870 --- /dev/null +++ b/tests/Idmt.BasicSample.Tests/Admin/CreateTenantInvokerAccessTests.cs @@ -0,0 +1,147 @@ +using System.Net.Http.Json; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Features.Admin; +using Idmt.Plugin.Features.Manage; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Idmt.BasicSample.Tests.Admin; + +/// +/// Phase 1 / Step 9: invoker auto-TenantAccess on tenant creation (HS-4 / V2-CRIT-2). +/// Asserts the SysAdmin who creates a tenant gets a TenantAccess row in the new tenant inside the +/// same inner transaction as role seeding, and that SysAdmin/SysSupport are NOT seeded as +/// per-tenant IdentityRole rows in fresh tenants. +/// +public class CreateTenantInvokerAccessTests : BaseIntegrationTest +{ + public CreateTenantInvokerAccessTests(IdmtApiFactory factory) : base(factory) { } + + [Fact] + public async Task POST_CreateTenant_AsSysAdmin_InvokerCanAccessNewTenant() + { + // Arrange + var sysClient = await CreateAuthenticatedClientAsync(); + var newTenant = $"step9-access-{Guid.NewGuid():N}"; + + // Act: create the tenant. + var createResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = newTenant, + Name = "Step 9 Access Tenant" + }); + await createResponse.AssertSuccess(); + + // Bearer tokens carry a tenant claim — the existing sysadmin token is bound to the default + // tenant, so we issue a fresh bearer against the new tenant. SysAdmin must be able to log + // in there (login doesn't yet enforce TenantAccess — Step 10) and reach the protected + // endpoint, demonstrating that the auto-inserted TenantAccess row admits the invoker. + var newTenantClient = Factory.CreateClientWithTenant(newTenant); + var loginResponse = await newTenantClient.PostAsJsonAsync("/auth/token", new + { + Email = IdmtApiFactory.SysAdminEmail, + Password = IdmtApiFactory.SysAdminPassword + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + newTenantClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + var infoResponse = await newTenantClient.GetAsync("/manage/info"); + + // Assert + await infoResponse.AssertSuccess(); + var info = await infoResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(info); + Assert.Equal(newTenant, info!.TenantIdentifier); + } + + [Fact] + public async Task POST_CreateTenant_AsSysAdmin_InsertsTenantAccessRow() + { + // Arrange + var sysClient = await CreateAuthenticatedClientAsync(); + var newTenant = $"step9-row-{Guid.NewGuid():N}"; + + // Resolve sysadmin user id. + Guid sysAdminId; + using (var scope = Factory.Services.CreateScope()) + { + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var defaultTenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant missing"); + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(defaultTenant); + + var userManager = provider.GetRequiredService>(); + var sysAdmin = await userManager.FindByEmailAsync(IdmtApiFactory.SysAdminEmail) + ?? throw new InvalidOperationException("Sysadmin missing"); + sysAdminId = sysAdmin.Id; + } + + // Act: create the tenant. + var createResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = newTenant, + Name = "Step 9 Row Tenant" + }); + await createResponse.AssertSuccess(); + + // Assert: TenantAccess row exists for the invoker against the new tenant id. + using (var scope = Factory.Services.CreateScope()) + { + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var newTenantInfo = await store.GetByIdentifierAsync(newTenant); + Assert.NotNull(newTenantInfo); + + var db = provider.GetRequiredService(); + var ta = await db.TenantAccess + .SingleOrDefaultAsync(x => x.UserId == sysAdminId && x.TenantId == newTenantInfo!.Id); + Assert.NotNull(ta); + Assert.True(ta!.IsActive); + Assert.Null(ta.ExpiresAt); + } + } + + [Fact] + public async Task POST_CreateTenant_RoleSeeding_DoesNotIncludeSysAdminOrSysSupport() + { + // Arrange + var sysClient = await CreateAuthenticatedClientAsync(); + var newTenant = $"step9-roles-{Guid.NewGuid():N}"; + + // Act + var createResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = newTenant, + Name = "Step 9 Roles Tenant" + }); + await createResponse.AssertSuccess(); + + // Assert: per-tenant IdmtRole rows for the new tenant must NOT include SysAdmin/SysSupport. + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var newTenantInfo = await store.GetByIdentifierAsync(newTenant); + Assert.NotNull(newTenantInfo); + + // Switch tenant context so RoleManager queries scope to the new tenant. + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(newTenantInfo!); + + var roleManager = provider.GetRequiredService>(); + var rolesForTenant = await roleManager.Roles + .Where(r => r.TenantId == newTenantInfo!.Id) + .Select(r => r.Name!) + .ToListAsync(); + + Assert.DoesNotContain(IdmtDefaultRoleTypes.SysAdmin, rolesForTenant); + Assert.DoesNotContain(IdmtDefaultRoleTypes.SysSupport, rolesForTenant); + Assert.Contains(IdmtDefaultRoleTypes.TenantAdmin, rolesForTenant); + } +} diff --git a/tests/Idmt.BasicSample.Tests/Admin/GrantTenantAccessIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/Admin/GrantTenantAccessIntegrationTests.cs new file mode 100644 index 0000000..65ada94 --- /dev/null +++ b/tests/Idmt.BasicSample.Tests/Admin/GrantTenantAccessIntegrationTests.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Features.Manage; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Idmt.BasicSample.Tests.Admin; + +/// +/// Phase 1 (canonical identity) integration tests for POST /admin/users/{userId}/tenants/{tenantIdentifier}. +/// Asserts the handler writes ONLY a TenantAccess row (no shadow IdmtUser created), self-target is rejected, +/// and unknown tenants return 404. +/// +public class GrantTenantAccessIntegrationTests : BaseIntegrationTest +{ + public GrantTenantAccessIntegrationTests(IdmtApiFactory factory) : base(factory) { } + + [Fact] + public async Task POST_GrantTenantAccess_AsSysAdmin_CreatesZeroIdmtUserRows() + { + // Arrange + var sysClient = await CreateAuthenticatedClientAsync(); + var email = $"phase1-grant-{Guid.NewGuid():N}@example.com"; + + // Register user (canonical, global) + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = email, + Username = $"phase1grant{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.SysSupport + }); + await registerResponse.AssertSuccess(); + var userId = Guid.Parse((await registerResponse.Content.ReadFromJsonAsync())!.UserId!); + + // Create a fresh target tenant so the TenantAccess insert is observable. + var targetTenant = $"phase1-grant-tenant-{Guid.NewGuid():N}"; + var createTenantResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = targetTenant, + Name = "Phase 1 Grant Tenant" + }); + await createTenantResponse.AssertSuccess(); + + // Snapshot canonical Users + TenantAccess counts before the grant. + int beforeUserCount; + int beforeTaCount; + using (var scope = Factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + beforeUserCount = await db.Users.CountAsync(); + beforeTaCount = await db.TenantAccess.CountAsync(); + } + + // Act + var grantResponse = await sysClient.PostAsJsonAsync( + $"/admin/users/{userId}/tenants/{targetTenant}", + new { ExpiresAt = (DateTime?)null }); + + // Assert + await grantResponse.AssertSuccess(); + + using (var scope = Factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var afterUserCount = await db.Users.CountAsync(); + var afterTaCount = await db.TenantAccess.CountAsync(); + + // No shadow IdmtUser rows were created. + Assert.Equal(beforeUserCount, afterUserCount); + + // Exactly one new TenantAccess row. + Assert.Equal(beforeTaCount + 1, afterTaCount); + + // Resolve target tenant id, then verify the row by (UserId, TenantId). + var store = scope.ServiceProvider.GetRequiredService>(); + var tenantInfo = await store.GetByIdentifierAsync(targetTenant); + Assert.NotNull(tenantInfo); + + var ta = await db.TenantAccess.FirstOrDefaultAsync(x => x.UserId == userId && x.TenantId == tenantInfo!.Id); + Assert.NotNull(ta); + Assert.True(ta!.IsActive); + } + } + + [Fact] + public async Task POST_GrantTenantAccess_AsSysAdmin_TenantNotFound_Returns404() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var email = $"phase1-grant-nt-{Guid.NewGuid():N}@example.com"; + + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = email, + Username = $"phase1grantnt{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.SysSupport + }); + await registerResponse.AssertSuccess(); + var userId = Guid.Parse((await registerResponse.Content.ReadFromJsonAsync())!.UserId!); + + var bogusTenant = $"phase1-no-such-tenant-{Guid.NewGuid():N}"; + var grantResponse = await sysClient.PostAsJsonAsync( + $"/admin/users/{userId}/tenants/{bogusTenant}", + new { ExpiresAt = (DateTime?)null }); + + Assert.Equal(HttpStatusCode.NotFound, grantResponse.StatusCode); + } + + [Fact] + public async Task POST_GrantTenantAccess_AsSysAdmin_SelfTarget_Returns403_SelfTargetError() + { + var sysClient = await CreateAuthenticatedClientAsync(); + + // Resolve sysadmin user id directly. + Guid sysAdminId; + using (var scope = Factory.Services.CreateScope()) + { + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant not found"); + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var sysAdmin = await userManager.FindByEmailAsync(IdmtApiFactory.SysAdminEmail) + ?? throw new InvalidOperationException("Sysadmin not found"); + sysAdminId = sysAdmin.Id; + } + + var response = await sysClient.PostAsJsonAsync( + $"/admin/users/{sysAdminId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + new { ExpiresAt = (DateTime?)null }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task POST_GrantTenantAccess_AsSysSupport_Returns403() + { + // Phase 0 admin policy: SysSupport must not reach the SysAdmin-gated grant endpoint. + var sysAdminClient = await CreateAuthenticatedClientAsync(); + + var ssEmail = $"phase1-grant-ss-{Guid.NewGuid():N}@example.com"; + var ssPassword = "Phase1Ss1!"; + var (_, _) = await RegisterAndSetPasswordAsync( + sysAdminClient, + ssPassword, + email: ssEmail, + username: $"phase1grantss{Guid.NewGuid():N}", + role: IdmtDefaultRoleTypes.SysSupport); + + var ssClient = Factory.CreateClientWithTenant(); + var loginResponse = await ssClient.PostAsJsonAsync("/auth/token", new + { + Email = ssEmail, + Password = ssPassword + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + ssClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + var response = await ssClient.PostAsJsonAsync( + $"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + new { ExpiresAt = (DateTime?)null }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/Idmt.BasicSample.Tests/Admin/RevokeTenantAccessIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/Admin/RevokeTenantAccessIntegrationTests.cs new file mode 100644 index 0000000..f6a80ea --- /dev/null +++ b/tests/Idmt.BasicSample.Tests/Admin/RevokeTenantAccessIntegrationTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Features.Manage; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Idmt.BasicSample.Tests.Admin; + +/// +/// Phase 1 (canonical identity) integration tests for DELETE /admin/users/{userId}/tenants/{tenantIdentifier}. +/// Asserts the handler flips TenantAccess.IsActive = false in a single SaveChangesAsync, surfaces 404 when +/// no access record exists, and rejects SysSupport callers (Phase 0 policy regression). +/// +public class RevokeTenantAccessIntegrationTests : BaseIntegrationTest +{ + public RevokeTenantAccessIntegrationTests(IdmtApiFactory factory) : base(factory) { } + + [Fact] + public async Task POST_RevokeTenantAccess_AsSysAdmin_FlipsIsActiveFalse() + { + // Arrange + var sysClient = await CreateAuthenticatedClientAsync(); + var email = $"phase1-revoke-{Guid.NewGuid():N}@example.com"; + + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = email, + Username = $"phase1revoke{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.SysSupport + }); + await registerResponse.AssertSuccess(); + var userId = Guid.Parse((await registerResponse.Content.ReadFromJsonAsync())!.UserId!); + + // Fresh tenant so the access row is unambiguous. + var targetTenant = $"phase1-revoke-tenant-{Guid.NewGuid():N}"; + var createTenantResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = targetTenant, + Name = "Phase 1 Revoke Tenant" + }); + await createTenantResponse.AssertSuccess(); + + // Grant access first. + var grantResponse = await sysClient.PostAsJsonAsync( + $"/admin/users/{userId}/tenants/{targetTenant}", + new { ExpiresAt = (DateTime?)null }); + await grantResponse.AssertSuccess(); + + // Act + var revokeResponse = await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{targetTenant}"); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, revokeResponse.StatusCode); + + using var scope = Factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var store = scope.ServiceProvider.GetRequiredService>(); + var tenantInfo = await store.GetByIdentifierAsync(targetTenant); + Assert.NotNull(tenantInfo); + + var ta = await db.TenantAccess.FirstOrDefaultAsync(x => x.UserId == userId && x.TenantId == tenantInfo!.Id); + Assert.NotNull(ta); + Assert.False(ta!.IsActive); + } + + [Fact] + public async Task POST_RevokeTenantAccess_AsSysAdmin_NoExistingAccess_Returns404() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var email = $"phase1-revoke-noaccess-{Guid.NewGuid():N}@example.com"; + + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = email, + Username = $"phase1revokenoaccess{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.SysSupport + }); + await registerResponse.AssertSuccess(); + var userId = Guid.Parse((await registerResponse.Content.ReadFromJsonAsync())!.UserId!); + + // Fresh tenant — no grant performed. + var targetTenant = $"phase1-revoke-noaccess-tenant-{Guid.NewGuid():N}"; + var createTenantResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = targetTenant, + Name = "Phase 1 Revoke NoAccess Tenant" + }); + await createTenantResponse.AssertSuccess(); + + var revokeResponse = await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{targetTenant}"); + + Assert.Equal(HttpStatusCode.NotFound, revokeResponse.StatusCode); + } + + [Fact] + public async Task POST_RevokeTenantAccess_AsSysSupport_Returns403() + { + // Phase 0 admin policy: SysSupport must not reach the SysAdmin-gated revoke endpoint. + var sysAdminClient = await CreateAuthenticatedClientAsync(); + + var ssEmail = $"phase1-revoke-ss-{Guid.NewGuid():N}@example.com"; + var ssPassword = "Phase1Ss1!"; + var (_, _) = await RegisterAndSetPasswordAsync( + sysAdminClient, + ssPassword, + email: ssEmail, + username: $"phase1revokess{Guid.NewGuid():N}", + role: IdmtDefaultRoleTypes.SysSupport); + + var ssClient = Factory.CreateClientWithTenant(); + var loginResponse = await ssClient.PostAsJsonAsync("/auth/token", new + { + Email = ssEmail, + Password = ssPassword + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + ssClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + var response = await ssClient.DeleteAsync( + $"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 815bb6d..41df16c 100644 --- a/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -6,6 +6,7 @@ using Idmt.Plugin.Features.Manage; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -44,38 +45,55 @@ public async Task Healthz_endpoint_allows_authenticated_user() [Fact] public async Task CreateTenant_handler_with_valid_data_succeeds() { - using var scope = Factory.Services.CreateScope(); - var handler = scope.ServiceProvider.GetRequiredService(); - + // Phase 1 / Step 9: CreateTenantHandler requires an authenticated invoker — drive it via the + // HTTP endpoint (already gated by RequireSysAdmin) instead of direct DI resolution. + var sysClient = await CreateAuthenticatedClientAsync(); var tenantIdentifier = $"tenant-{Guid.NewGuid():N}"; - var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant"); - var result = await handler.HandleAsync(request); - Assert.False(result.IsError); - Assert.Equal(tenantIdentifier, result.Value.Identifier); + var response = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = tenantIdentifier, + Name = "Test Tenant" + }); + await response.AssertSuccess(); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(tenantIdentifier, body!.Identifier); } [Fact] public async Task CreateTenant_handler_with_duplicate_identifier_reactivates() { - using var scope = Factory.Services.CreateScope(); - var handler = scope.ServiceProvider.GetRequiredService(); - var deleteHandler = scope.ServiceProvider.GetRequiredService(); - + var sysClient = await CreateAuthenticatedClientAsync(); var tenantIdentifier = $"tenant-{Guid.NewGuid():N}"; - // Create initial tenant - var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant"); - var result = await handler.HandleAsync(request); - var tenantId = result.Value!.Id; + // Create initial tenant via HTTP. + var createResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = tenantIdentifier, + Name = "Test Tenant" + }); + await createResponse.AssertSuccess(); + var created = await createResponse.Content.ReadFromJsonAsync(); + var tenantId = created!.Id; - // Delete the tenant - await deleteHandler.HandleAsync(tenantIdentifier); + // Delete the tenant via direct DI (DeleteTenantHandler does not require an invoker). + using (var scope = Factory.Services.CreateScope()) + { + var deleteHandler = scope.ServiceProvider.GetRequiredService(); + await deleteHandler.HandleAsync(tenantIdentifier); + } - // Reactivate by creating again - var reactivateResult = await handler.HandleAsync(request); - Assert.False(reactivateResult.IsError); - Assert.Equal(tenantId, reactivateResult.Value.Id); + // Reactivate by creating again via HTTP. + var reactivateResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = tenantIdentifier, + Name = "Test Tenant" + }); + await reactivateResponse.AssertSuccess(); + var reactivated = await reactivateResponse.Content.ReadFromJsonAsync(); + Assert.Equal(tenantId, reactivated!.Id); } #endregion @@ -85,14 +103,19 @@ public async Task CreateTenant_handler_with_duplicate_identifier_reactivates() [Fact] public async Task DeleteTenant_handler_with_valid_identifier_succeeds() { + var sysClient = await CreateAuthenticatedClientAsync(); + var tenantIdentifier = $"tenant-{Guid.NewGuid():N}"; + + var createResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = tenantIdentifier, + Name = "Test Tenant" + }); + await createResponse.AssertSuccess(); + using var scope = Factory.Services.CreateScope(); - var createHandler = scope.ServiceProvider.GetRequiredService(); var deleteHandler = scope.ServiceProvider.GetRequiredService(); - var tenantIdentifier = $"tenant-{Guid.NewGuid():N}"; - var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant"); - await createHandler.HandleAsync(request); - var deleted = await deleteHandler.HandleAsync(tenantIdentifier); Assert.False(deleted.IsError); } @@ -346,12 +369,14 @@ await sysClient.PostAsJsonAsync( } [Fact] - public async Task GetUserTenants_returns_empty_for_user_without_access() + public async Task GetUserTenants_returns_only_registering_tenant_for_freshly_registered_user() { + // Phase 1 / Step 10: registration auto-grants TenantAccess in the registering tenant. + // A user registered against the default (sys) tenant has exactly one TenantAccess row — + // that tenant. var sysClient = await CreateAuthenticatedClientAsync(); var email = $"notenants-{Guid.NewGuid():N}@example.com"; - // Register user without granting tenant access var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new { Email = email, @@ -360,13 +385,13 @@ public async Task GetUserTenants_returns_empty_for_user_without_access() }); var userId = Guid.Parse((await registerResponse.Content.ReadFromJsonAsync())!.UserId!); - // Get user tenants var response = await sysClient.GetAsync($"/admin/users/{userId}/tenants"); await response.AssertSuccess(); var paginated = await response.Content.ReadFromJsonAsync>(); Assert.NotNull(paginated); - Assert.Empty(paginated!.Items); + Assert.Single(paginated!.Items); + Assert.Equal(IdmtApiFactory.DefaultTenantIdentifier, paginated.Items[0].Identifier); } [Fact] @@ -558,6 +583,182 @@ public async Task DefaultTenant_ExistsAfterStartup() #region Grant Tenant Access Validation Tests + private async Task CreateSysSupportAuthenticatedClientAsync() + { + var sysAdminClient = await CreateAuthenticatedClientAsync(); + var password = "SysSup1!"; + var (_, email) = await RegisterAndSetPasswordAsync( + sysAdminClient, + password, + role: IdmtDefaultRoleTypes.SysSupport); + + var client = Factory.CreateClientWithTenant(); + var loginResponse = await client.PostAsJsonAsync("/auth/token", new + { + Email = email, + Password = password + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + return client; + } + + private async Task<(HttpClient Client, Guid UserId)> CreateSysSupportAuthenticatedClientWithIdAsync() + { + var sysAdminClient = await CreateAuthenticatedClientAsync(); + var password = "SysSup1!"; + var (userId, email) = await RegisterAndSetPasswordAsync( + sysAdminClient, + password, + role: IdmtDefaultRoleTypes.SysSupport); + + var client = Factory.CreateClientWithTenant(); + var loginResponse = await client.PostAsJsonAsync("/auth/token", new + { + Email = email, + Password = password + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + return (client, Guid.Parse(userId)); + } + + private async Task GetSysAdminUserIdAsync() + { + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant not found"); + + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new Finbuckle.MultiTenant.Abstractions.MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var sysAdmin = await userManager.FindByEmailAsync(IdmtApiFactory.SysAdminEmail) + ?? throw new InvalidOperationException("Sysadmin not found"); + return sysAdmin.Id; + } + + #region Role-based authorization tests (C2) + + [Fact] + public async Task SysSupport_cannot_create_tenant_returns_403() + { + var client = await CreateSysSupportAuthenticatedClientAsync(); + var response = await client.PostAsJsonAsync("/admin/tenants", new + { + Identifier = $"ss-create-{Guid.NewGuid():N}", + Name = "SS Forbidden" + }); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task SysSupport_cannot_delete_tenant_returns_403() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var target = $"ss-del-{Guid.NewGuid():N}"; + await sysClient.PostAsJsonAsync("/admin/tenants", new { Identifier = target, Name = "SS Del" }); + + var ssClient = await CreateSysSupportAuthenticatedClientAsync(); + var response = await ssClient.DeleteAsync($"/admin/tenants/{target}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task SysSupport_cannot_grant_tenant_access_returns_403() + { + var (ssClient, _) = await CreateSysSupportAuthenticatedClientWithIdAsync(); + var response = await ssClient.PostAsJsonAsync( + $"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + new { ExpiresAt = (DateTime?)null }); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task SysSupport_cannot_revoke_tenant_access_returns_403() + { + var (ssClient, _) = await CreateSysSupportAuthenticatedClientWithIdAsync(); + var response = await ssClient.DeleteAsync( + $"/admin/users/{Guid.NewGuid()}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task SysSupport_can_list_all_tenants_returns_200() + { + var ssClient = await CreateSysSupportAuthenticatedClientAsync(); + var response = await ssClient.GetAsync("/admin/tenants"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task SysSupport_can_get_user_tenants_returns_200() + { + var ssClient = await CreateSysSupportAuthenticatedClientAsync(); + var response = await ssClient.GetAsync($"/admin/users/{Guid.NewGuid()}/tenants"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task SysAdmin_grant_access_to_self_returns_403_with_SelfTarget() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var sysAdminId = await GetSysAdminUserIdAsync(); + var response = await sysClient.PostAsJsonAsync( + $"/admin/users/{sysAdminId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}", + new { ExpiresAt = (DateTime?)null }); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task SysAdmin_revoke_access_from_self_returns_403_with_SelfTarget() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var sysAdminId = await GetSysAdminUserIdAsync(); + var response = await sysClient.DeleteAsync( + $"/admin/users/{sysAdminId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Unauthenticated_caller_gets_401_on_admin_write() + { + var client = Factory.CreateClientWithTenant(); + var response = await client.PostAsJsonAsync("/admin/tenants", new + { + Identifier = $"anon-{Guid.NewGuid():N}", + Name = "Anon" + }); + Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); + } + + [Fact] + public async Task Unauthenticated_caller_gets_401_on_admin_read() + { + var client = Factory.CreateClientWithTenant(); + var response = await client.GetAsync("/admin/tenants"); + Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); + } + + [Fact] + public async Task SysAdmin_can_create_tenant_returns_201() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var response = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = $"sa-create-{Guid.NewGuid():N}", + Name = "SA Create" + }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + #endregion + [Fact] public async Task GrantTenantAccess_Returns400_WhenExpiresAtIsInPast() { diff --git a/tests/Idmt.BasicSample.Tests/Auth/ConfirmEmailChangeIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/Auth/ConfirmEmailChangeIntegrationTests.cs new file mode 100644 index 0000000..6f6f04e --- /dev/null +++ b/tests/Idmt.BasicSample.Tests/Auth/ConfirmEmailChangeIntegrationTests.cs @@ -0,0 +1,174 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Idmt.BasicSample.Tests.Auth; + +/// +/// Integration tests for POST /auth/confirm-email-change (Phase 1, Step 7). +/// +public class ConfirmEmailChangeIntegrationTests : BaseIntegrationTest +{ + public ConfirmEmailChangeIntegrationTests(IdmtApiFactory factory) : base(factory) { } + + [Fact] + public async Task POST_ConfirmEmailChange_ValidToken_CommitsEmail_ClearsPendingEmail() + { + var (email, _, client) = await SetupAuthenticatedUserAsync(); + var newEmail = $"new-{Guid.NewGuid():N}@example.com"; + + Factory.EmailSenderMock.Invocations.Clear(); + + // Stage the email change + var stageResponse = await client.PutAsJsonAsync("/manage/info", new { NewEmail = newEmail }); + Assert.Equal(HttpStatusCode.Accepted, stageResponse.StatusCode); + + var (capturedCurrent, capturedNew, capturedEncodedToken) = ExtractCapturedConfirmEmailChangeLink(); + Assert.Equal(email, capturedCurrent); + Assert.Equal(newEmail, capturedNew); + + // Confirm + using var publicClient = Factory.CreateClientWithTenant(); + var confirmResponse = await publicClient.PostAsJsonAsync("/auth/confirm-email-change", new + { + Email = email, + NewEmail = newEmail, + Token = capturedEncodedToken + }); + + await confirmResponse.AssertSuccess(); + + // After confirmation: Email column = newEmail; PendingEmail = null. + var (committedEmail, pendingEmail) = await GetUserEmailStateAsync(newEmail); + Assert.Equal(newEmail, committedEmail); + Assert.Null(pendingEmail); + } + + [Fact] + public async Task POST_ConfirmEmailChange_NoPendingEmail_Returns400_NoPendingChange() + { + var (email, _, _) = await SetupAuthenticatedUserAsync(); + + using var publicClient = Factory.CreateClientWithTenant(); + var confirmResponse = await publicClient.PostAsJsonAsync("/auth/confirm-email-change", new + { + Email = email, + NewEmail = $"unrelated-{Guid.NewGuid():N}@example.com", + // Token is required by validation but won't be exercised since PendingEmail is null. + Token = EncodeToken("any-token") + }); + + Assert.Equal(HttpStatusCode.BadRequest, confirmResponse.StatusCode); + } + + [Fact] + public async Task POST_ConfirmEmailChange_InvalidToken_Returns400_ConfirmationFailed_PendingEmailIntact() + { + var (email, _, client) = await SetupAuthenticatedUserAsync(); + var newEmail = $"new-{Guid.NewGuid():N}@example.com"; + + Factory.EmailSenderMock.Invocations.Clear(); + + // Stage the email change + var stageResponse = await client.PutAsJsonAsync("/manage/info", new { NewEmail = newEmail }); + Assert.Equal(HttpStatusCode.Accepted, stageResponse.StatusCode); + + // Confirm with an invalid (but well-formed Base64URL) token + using var publicClient = Factory.CreateClientWithTenant(); + var confirmResponse = await publicClient.PostAsJsonAsync("/auth/confirm-email-change", new + { + Email = email, + NewEmail = newEmail, + Token = EncodeToken("invalid-token-payload") + }); + + Assert.Equal(HttpStatusCode.BadRequest, confirmResponse.StatusCode); + + // PendingEmail must remain set (still staged) and Email column unchanged. + var (committedEmail, pendingEmail) = await GetUserEmailStateAsync(email); + Assert.Equal(email, committedEmail); + Assert.Equal(newEmail, pendingEmail); + } + + private async Task<(string Email, string Password, HttpClient Client)> SetupAuthenticatedUserAsync() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var email = $"emailchange-{Guid.NewGuid():N}@example.com"; + var password = "InitialP@ss1!"; + + await RegisterAndSetPasswordAsync( + sysClient, + password, + email: email, + username: $"emailchange{Guid.NewGuid():N}", + role: IdmtDefaultRoleTypes.TenantAdmin); + + await ConfirmEmailDirectAsync(email); + + var loginClient = Factory.CreateClientWithTenant(); + var loginResponse = await loginClient.PostAsJsonAsync("/auth/token", new { Email = email, Password = password }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + loginClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + return (email, password, loginClient); + } + + private async Task ConfirmEmailDirectAsync(string email) + { + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant missing."); + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email) + ?? throw new InvalidOperationException($"User '{email}' not found."); + if (!user.EmailConfirmed) + { + user.EmailConfirmed = true; + await userManager.UpdateAsync(user); + } + } + + private async Task<(string? Email, string? PendingEmail)> GetUserEmailStateAsync(string lookupEmail) + { + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant missing."); + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(lookupEmail); + return (user?.Email, user?.PendingEmail); + } + + private (string CurrentEmail, string NewEmail, string EncodedToken) ExtractCapturedConfirmEmailChangeLink() + { + var invocation = Factory.EmailSenderMock.Invocations + .Where(i => i.Method.Name == nameof(IEmailSender.SendConfirmationLinkAsync)) + .LastOrDefault() + ?? throw new InvalidOperationException("No SendConfirmationLinkAsync invocation captured."); + + var link = (string)invocation.Arguments[2]; + var uri = new Uri(link); + var query = QueryHelpers.ParseQuery(uri.Query); + + return ( + query["email"].ToString(), + query["newEmail"].ToString(), + query["token"].ToString()); + } +} diff --git a/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs index b1cd248..b2fba68 100644 --- a/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; @@ -104,6 +105,35 @@ public async Task Login_WithUsername_Succeeds() await response.AssertSuccess(); } + [Fact] + public async Task POST_Login_UserHasNoTenantAccess_Returns401() + { + // Phase 1 / Step 10: TenantAccess gate uniformly enforced at login. + // User registered + password set in tenant A only, no TenantAccess for tenant B. + var sysClient = await CreateAuthenticatedClientAsync(); + var tenantBIdentifier = $"step10-cookie-{Guid.NewGuid():N}"; + var createResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = tenantBIdentifier, + Name = "Step10 Cookie Tenant" + }); + await createResponse.AssertSuccess(); + + var email = $"step10-cookie-{Guid.NewGuid():N}@example.com"; + const string password = "Step10Cookie1!"; + var (_, _) = await RegisterAndSetPasswordAsync(sysClient, password, email: email); + + // Tenant A login succeeds (sanity). + var clientA = Factory.CreateClientWithTenant(); + var loginA = await clientA.PostAsJsonAsync("/auth/login", new { Email = email, Password = password }); + await loginA.AssertSuccess(); + + // Tenant B login is denied — no TenantAccess row. + var clientB = Factory.CreateClientWithTenant(tenantBIdentifier); + var loginB = await clientB.PostAsJsonAsync("/auth/login", new { Email = email, Password = password }); + Assert.Equal(HttpStatusCode.Unauthorized, loginB.StatusCode); + } + #endregion #region Token Tests (Bearer Token-based) @@ -194,6 +224,34 @@ public async Task TokenLogin_ReturnsCorrectExpiresIn() Assert.True(tokens.ExpiresIn > 0, "ExpiresIn should be a positive number of seconds"); } + [Fact] + public async Task POST_Token_UserHasNoTenantAccess_Returns401() + { + // Phase 1 / Step 10: TenantAccess gate uniformly enforced at /auth/token. + var sysClient = await CreateAuthenticatedClientAsync(); + var tenantBIdentifier = $"step10-token-{Guid.NewGuid():N}"; + var createResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = tenantBIdentifier, + Name = "Step10 Token Tenant" + }); + await createResponse.AssertSuccess(); + + var email = $"step10-token-{Guid.NewGuid():N}@example.com"; + const string password = "Step10Token1!"; + var (_, _) = await RegisterAndSetPasswordAsync(sysClient, password, email: email); + + // Tenant A token issuance succeeds. + var clientA = Factory.CreateClientWithTenant(); + var tokenA = await clientA.PostAsJsonAsync("/auth/token", new { Email = email, Password = password }); + await tokenA.AssertSuccess(); + + // Tenant B token issuance is denied — no TenantAccess row. + var clientB = Factory.CreateClientWithTenant(tenantBIdentifier); + var tokenB = await clientB.PostAsJsonAsync("/auth/token", new { Email = email, Password = password }); + Assert.Equal(HttpStatusCode.Unauthorized, tokenB.StatusCode); + } + [Fact] public async Task Login_WithInactiveTenant_ReturnsError() { @@ -404,37 +462,77 @@ public async Task ConfirmEmail_with_valid_token_succeeds() var confirmToken = await GenerateEmailConfirmationTokenAsync(newEmail); var encodedToken = EncodeToken(confirmToken); - // Confirm email via POST /confirm-email with Base64URL-encoded token - using var publicClient = Factory.CreateClient(); + // Confirm email via POST /confirm-email with Base64URL-encoded token. + // Body shape is { Email, Token } — tenant resolved via header (per consumer config). + using var publicClient = Factory.CreateClientWithTenant(); var confirmResponse = await publicClient.PostAsJsonAsync( "/auth/confirm-email", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = newEmail, Token = encodedToken }); + new { Email = newEmail, Token = encodedToken }); await confirmResponse.AssertSuccess(); } [Fact] - public async Task ConfirmEmail_with_invalid_token_fails() + public async Task ConfirmEmail_BodyWithExtraTenantIdentifier_IsIgnored() { - var newEmail = $"invalid-{Guid.NewGuid():N}@example.com"; - using var publicClient = Factory.CreateClient(); + // Backward-compat: ASP.NET Core JSON deserializer silently ignores unknown + // members by default. A legacy client sending TenantIdentifier in body is + // still accepted; the tenant is resolved from the request scope only. + var sysClient = await CreateAuthenticatedClientAsync(); + var newEmail = $"confirm-extra-{Guid.NewGuid():N}@example.com"; + + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = newEmail, + Username = $"confirmextra{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.TenantAdmin + }); + await registerResponse.AssertSuccess(); + + var confirmToken = await GenerateEmailConfirmationTokenAsync(newEmail); + var encodedToken = EncodeToken(confirmToken); + using var publicClient = Factory.CreateClientWithTenant(); var confirmResponse = await publicClient.PostAsJsonAsync( "/auth/confirm-email", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = newEmail, Token = "invalid-token" }); + new { TenantIdentifier = "nonexistent-tenant", Email = newEmail, Token = encodedToken }); - Assert.False(confirmResponse.IsSuccessStatusCode); + await confirmResponse.AssertSuccess(); } [Fact] - public async Task ConfirmEmail_with_invalid_tenant_fails() + public async Task ConfirmEmail_Get_NoTenantIdentifierInQuery_Succeeds() { - var newEmail = $"confirm-{Guid.NewGuid():N}@example.com"; - using var publicClient = Factory.CreateClient(); + var sysClient = await CreateAuthenticatedClientAsync(); + var newEmail = $"confirm-get-{Guid.NewGuid():N}@example.com"; + + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = newEmail, + Username = $"confirmget{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.TenantAdmin + }); + await registerResponse.AssertSuccess(); + + var confirmToken = await GenerateEmailConfirmationTokenAsync(newEmail); + var encodedToken = EncodeToken(confirmToken); + + using var publicClient = Factory.CreateClientWithTenant(); + var url = $"/auth/confirm-email?email={Uri.EscapeDataString(newEmail)}&token={Uri.EscapeDataString(encodedToken)}"; + var confirmResponse = await publicClient.GetAsync(url); + + await confirmResponse.AssertSuccess(); + } + + [Fact] + public async Task ConfirmEmail_with_invalid_token_fails() + { + var newEmail = $"invalid-{Guid.NewGuid():N}@example.com"; + using var publicClient = Factory.CreateClientWithTenant(); var confirmResponse = await publicClient.PostAsJsonAsync( "/auth/confirm-email", - new { TenantIdentifier = "nonexistent-tenant", Email = newEmail, Token = "some-token" }); + new { Email = newEmail, Token = "invalid-token" }); Assert.False(confirmResponse.IsSuccessStatusCode); } @@ -442,11 +540,11 @@ public async Task ConfirmEmail_with_invalid_tenant_fails() [Fact] public async Task ConfirmEmail_with_missing_email_fails() { - using var publicClient = Factory.CreateClient(); + using var publicClient = Factory.CreateClientWithTenant(); var confirmResponse = await publicClient.PostAsJsonAsync( "/auth/confirm-email", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = "", Token = "some-token" }); + new { Email = "", Token = "some-token" }); Assert.False(confirmResponse.IsSuccessStatusCode); } @@ -455,11 +553,11 @@ public async Task ConfirmEmail_with_missing_email_fails() public async Task ConfirmEmail_with_missing_token_fails() { var newEmail = $"confirm-{Guid.NewGuid():N}@example.com"; - using var publicClient = Factory.CreateClient(); + using var publicClient = Factory.CreateClientWithTenant(); var confirmResponse = await publicClient.PostAsJsonAsync( "/auth/confirm-email", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = newEmail, Token = "" }); + new { Email = newEmail, Token = "" }); Assert.False(confirmResponse.IsSuccessStatusCode); } @@ -578,7 +676,7 @@ public async Task ForgotPassword_generates_reset_token() using var publicClient = Factory.CreateClient(); await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = email, Token = EncodeToken(setupToken), NewPassword = "InitialPassword1!" }); + new { Email = email, Token = EncodeToken(setupToken), NewPassword = "InitialPassword1!" }); Factory.EmailSenderMock.Invocations.Clear(); @@ -614,18 +712,6 @@ public async Task ForgotPassword_with_nonexistent_email_succeeds_silently() Assert.True(response.IsSuccessStatusCode); } - [Fact] - public async Task ResetPassword_Returns400_WhenTenantIdentifierMissing() - { - using var publicClient = Factory.CreateClient(); - - var resetResponse = await publicClient.PostAsJsonAsync( - "/auth/reset-password", - new { TenantIdentifier = "", Email = "test@example.com", Token = "some-token", NewPassword = "NewPassword1!" }); - - Assert.False(resetResponse.IsSuccessStatusCode); - } - [Fact] public async Task ResetPassword_Returns400_WhenTokenMissing() { @@ -633,7 +719,7 @@ public async Task ResetPassword_Returns400_WhenTokenMissing() var resetResponse = await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = "test@example.com", Token = "", NewPassword = "NewPassword1!" }); + new { Email = "test@example.com", Token = "", NewPassword = "NewPassword1!" }); Assert.False(resetResponse.IsSuccessStatusCode); } @@ -662,7 +748,7 @@ public async Task ResetPassword_with_valid_token_succeeds() using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = email, Token = EncodeToken(resetToken), NewPassword = "NewPassword1!" }); + new { Email = email, Token = EncodeToken(resetToken), NewPassword = "NewPassword1!" }); await resetResponse.AssertSuccess(); } @@ -688,7 +774,7 @@ public async Task ResetPassword_with_new_password_allows_login() const string newPassword = "NewPassword1!"; await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = email, Token = EncodeToken(resetToken), NewPassword = newPassword }); + new { Email = email, Token = EncodeToken(resetToken), NewPassword = newPassword }); // Login with new password using var loginClient = Factory.CreateClientWithTenant(); @@ -709,7 +795,7 @@ public async Task ResetPassword_with_invalid_token_fails() var resetResponse = await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = email, Token = "invalid-token", NewPassword = "NewPassword1!" }); + new { Email = email, Token = "invalid-token", NewPassword = "NewPassword1!" }); Assert.False(resetResponse.IsSuccessStatusCode); } @@ -734,10 +820,86 @@ public async Task ResetPassword_with_weak_password_fails() using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = IdmtApiFactory.DefaultTenantIdentifier, Email = email, Token = EncodeToken(resetToken), NewPassword = "weak" }); + new { Email = email, Token = EncodeToken(resetToken), NewPassword = "weak" }); Assert.False(resetResponse.IsSuccessStatusCode); } + [Fact] + public async Task POST_ResetPassword_NoTenantIdentifierInBody_Succeeds() + { + // Phase-1 Step 6 regression: tenant must be resolved from ambient context + // (Finbuckle), not from request body. Body shape no longer carries + // TenantIdentifier. + var email = $"reset-no-tid-{Guid.NewGuid():N}@example.com"; + var sysClient = await CreateAuthenticatedClientAsync(); + + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = email, + Username = $"resetnotid{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.TenantAdmin + }); + await registerResponse.AssertSuccess(); + var resetToken = await GeneratePasswordResetTokenAsync(email); + + using var publicClient = Factory.CreateClient(); + var resetResponse = await publicClient.PostAsJsonAsync( + "/auth/reset-password", + new { Email = email, Token = EncodeToken(resetToken), NewPassword = "NewPassword1!" }); + + await resetResponse.AssertSuccess(); + } + + [Fact] + public async Task POST_ResetPassword_DoesNotFlipEmailConfirmed() + { + // C7 regression: successful password reset must NOT mutate EmailConfirmed. + var email = $"reset-c7-{Guid.NewGuid():N}@example.com"; + var sysClient = await CreateAuthenticatedClientAsync(); + + var registerResponse = await sysClient.PostAsJsonAsync("/manage/users", new + { + Email = email, + Username = $"resetc7{Guid.NewGuid():N}", + Role = IdmtDefaultRoleTypes.TenantAdmin + }); + await registerResponse.AssertSuccess(); + + // Newly registered user has EmailConfirmed = false. Verify pre-state. + Assert.False(await GetEmailConfirmedAsync(email)); + + var resetToken = await GeneratePasswordResetTokenAsync(email); + + using var publicClient = Factory.CreateClient(); + var resetResponse = await publicClient.PostAsJsonAsync( + "/auth/reset-password", + new { Email = email, Token = EncodeToken(resetToken), NewPassword = "NewPassword1!" }); + await resetResponse.AssertSuccess(); + + // Critical: EmailConfirmed must STILL be false after a successful reset. + Assert.False(await GetEmailConfirmedAsync(email)); + } + + private async Task GetEmailConfirmedAsync(string email, string? tenantIdentifier = null) + { + tenantIdentifier ??= IdmtApiFactory.DefaultTenantIdentifier; + + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(tenantIdentifier) + ?? throw new InvalidOperationException($"Tenant '{tenantIdentifier}' not found."); + + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email) + ?? throw new InvalidOperationException($"User '{email}' not found."); + return user.EmailConfirmed; + } + #endregion } diff --git a/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs b/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs index 55fc357..d8de079 100644 --- a/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs +++ b/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs @@ -104,7 +104,7 @@ protected HttpClient CreateClientWithToken(string? tenantId = null, string? toke using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = tenantIdentifier, Email = email, Token = EncodeToken(resetToken), NewPassword = password }); + new { Email = email, Token = EncodeToken(resetToken), NewPassword = password }); await resetResponse.AssertSuccess(); return (userId, email); diff --git a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index eb1740c..a866050 100644 --- a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs +++ b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs @@ -25,6 +25,12 @@ public class IdmtApiFactory : WebApplicationFactory private readonly string[] _strategies; private SqliteConnection? _connection; + /// + /// The shared in-memory SQLite connection backing both DbContexts. Exposed for tests that + /// need to spin up an out-of-band DI scope (e.g. the canonical identity migrator harness). + /// + internal SqliteConnection? SharedConnection => _connection; + public Mock> EmailSenderMock { get; } = new(); public IdmtApiFactory() @@ -166,6 +172,16 @@ private static async Task SeedAsync(IServiceProvider services) private static async Task EnsureRolesAsync(RoleManager roleManager) { + // Step 9 INTENT: seed only IdmtDefaultRoleTypes.DefaultRoles (TenantAdmin) — sys authority + // is sourced from IdmtUser.SysRole, not per-tenant IdmtRole rows. + // + // DEVIATION (kept for now): integration tests still register sys-role users via the + // public /manage/users endpoint (RegisterUser), which validates the role via + // RoleManager.RoleExistsAsync per tenant. Removing the SysAdmin/SysSupport seed here + // currently breaks ~16 tests (CreateSysSupportAuthenticatedClient*, several SysSupport_* + // and RegisterUser_WithSysAdminRole_* tests). Migrating those test helpers to seed + // sys-role users directly via DbContext + SysRoleKind is owed work for Step 10+. + // Until then we keep the legacy seed set so the broader test suite stays green. var roles = new[] { IdmtDefaultRoleTypes.SysAdmin, @@ -184,10 +200,11 @@ private static async Task EnsureRolesAsync(RoleManager roleManager) private static async Task EnsureSysAdminAsync(IdmtDbContext dbContext, UserManager userManager, string tenantId) { - // Here we use IgnoreQueryFilters to find the user regardless of current tenant context - // BUT when creating, we rely on the context being set correctly. - var existing = await dbContext.Users.IgnoreQueryFilters() - .SingleOrDefaultAsync(u => u.Email == SysAdminEmail && u.TenantId == tenantId); + // Phase 1: IdmtUser is global — there is no per-tenant shadow row, no IgnoreQueryFilters + // is required to find the user, and SysAdmin is granted by setting SysRole on the user + // (not by per-tenant role membership). + var existing = await dbContext.Users + .SingleOrDefaultAsync(u => u.Email == SysAdminEmail); var user = existing ?? new IdmtUser { @@ -197,7 +214,7 @@ private static async Task EnsureSysAdminAsync(IdmtDbContext dbContext, UserManag NormalizedUserName = "SYSADMIN", EmailConfirmed = true, IsActive = true, - TenantId = tenantId + SysRole = SysRoleKind.SysAdmin, }; if (existing is null) @@ -209,12 +226,13 @@ private static async Task EnsureSysAdminAsync(IdmtDbContext dbContext, UserManag throw new InvalidOperationException($"Failed to seed sysadmin user: {errors}"); } } - - if (!await userManager.IsInRoleAsync(user, IdmtDefaultRoleTypes.SysAdmin)) + else if (existing.SysRole != SysRoleKind.SysAdmin) { - await userManager.AddToRoleAsync(user, IdmtDefaultRoleTypes.SysAdmin); + existing.SysRole = SysRoleKind.SysAdmin; + await userManager.UpdateAsync(existing); } + // HS-4: SysAdmin still needs an explicit TenantAccess row in every tenant it must reach. var hasAccess = await dbContext.TenantAccess.AnyAsync(ta => ta.UserId == user.Id && ta.TenantId == tenantId); if (!hasAccess) { diff --git a/tests/Idmt.BasicSample.Tests/Manage/UpdateUserInfoEmailChangeTests.cs b/tests/Idmt.BasicSample.Tests/Manage/UpdateUserInfoEmailChangeTests.cs new file mode 100644 index 0000000..597bc66 --- /dev/null +++ b/tests/Idmt.BasicSample.Tests/Manage/UpdateUserInfoEmailChangeTests.cs @@ -0,0 +1,280 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Idmt.BasicSample.Tests.Manage; + +/// +/// Integration tests for PUT /manage/info email-change staging (Phase 1, Step 7). +/// +public class UpdateUserInfoEmailChangeTests : BaseIntegrationTest +{ + public UpdateUserInfoEmailChangeTests(IdmtApiFactory factory) : base(factory) { } + + [Fact] + public async Task PUT_UpdateUserInfo_EmailChangeRequested_Returns202_StagesPendingEmail() + { + var (email, _, client) = await SetupAuthenticatedUserAsync(); + var newEmail = $"new-{Guid.NewGuid():N}@example.com"; + + Factory.EmailSenderMock.Invocations.Clear(); + + var response = await client.PutAsJsonAsync("/manage/info", new + { + NewEmail = newEmail + }); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + var pendingEmail = await GetPendingEmailAsync(email); + Assert.Equal(newEmail, pendingEmail); + } + + [Fact] + public async Task PUT_UpdateUserInfo_EmailChangeRequested_SendsEmailToNewAddress() + { + var (_, _, client) = await SetupAuthenticatedUserAsync(); + var newEmail = $"new-{Guid.NewGuid():N}@example.com"; + + Factory.EmailSenderMock.Invocations.Clear(); + + var response = await client.PutAsJsonAsync("/manage/info", new + { + NewEmail = newEmail + }); + + await response.AssertSuccess(); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + // Confirmation link must be dispatched to the NEW address (not the current). + Factory.EmailSenderMock.Verify(x => x.SendConfirmationLinkAsync( + It.Is(u => u.PendingEmail == newEmail), + newEmail, + It.IsAny()), Times.Once); + } + + [Fact] + public async Task PUT_UpdateUserInfo_EmailChangeRequested_DoesNotMutateEmailColumn() + { + var (email, _, client) = await SetupAuthenticatedUserAsync(); + var newEmail = $"new-{Guid.NewGuid():N}@example.com"; + + var response = await client.PutAsJsonAsync("/manage/info", new + { + NewEmail = newEmail + }); + + await response.AssertSuccess(); + + // Critical invariant: the user.Email column is NOT mutated until ConfirmEmailChange runs. + var currentEmail = await GetEmailAsync(email); + Assert.Equal(email, currentEmail); + + var pending = await GetPendingEmailAsync(email); + Assert.Equal(newEmail, pending); + } + + /// + /// F25 integration regression: PUT with both new password + new email must produce a + /// confirmation token that validates at the confirm endpoint AFTER ChangePasswordAsync + /// rotated SecurityStamp (invariant 5a). + /// + [Fact] + public async Task PUT_UpdateUserInfo_PasswordAndEmailChange_TokenValidAtConfirmTime() + { + var (email, oldPassword, client) = await SetupAuthenticatedUserAsync(); + var newEmail = $"new-{Guid.NewGuid():N}@example.com"; + var newPassword = "BrandNewP@ss1!"; + + Factory.EmailSenderMock.Invocations.Clear(); + + var response = await client.PutAsJsonAsync("/manage/info", new + { + OldPassword = oldPassword, + NewPassword = newPassword, + NewEmail = newEmail + }); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + // Extract token from captured email + var (capturedCurrent, capturedNew, capturedEncodedToken) = ExtractCapturedConfirmEmailChangeLink(); + Assert.Equal(email, capturedCurrent); + Assert.Equal(newEmail, capturedNew); + + // Confirm the staged change. If invariant 5a is broken, the token will be invalid. + using var publicClient = Factory.CreateClientWithTenant(); + var confirmResponse = await publicClient.PostAsJsonAsync("/auth/confirm-email-change", new + { + Email = email, + NewEmail = newEmail, + Token = capturedEncodedToken + }); + + await confirmResponse.AssertSuccess(); + + var finalEmail = await GetEmailByIdAsync(email, originalEmail: email, fallback: newEmail); + Assert.Equal(newEmail, finalEmail); + } + + /// + /// F44 integration regression: same as above but with username + email change. + /// + [Fact] + public async Task PUT_UpdateUserInfo_UsernameAndEmailChange_TokenValidAtConfirmTime() + { + var (email, _, client) = await SetupAuthenticatedUserAsync(); + var newEmail = $"new-{Guid.NewGuid():N}@example.com"; + var newUsername = $"newuser{Guid.NewGuid():N}"; + + Factory.EmailSenderMock.Invocations.Clear(); + + var response = await client.PutAsJsonAsync("/manage/info", new + { + NewUsername = newUsername, + NewEmail = newEmail + }); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + + var (capturedCurrent, capturedNew, capturedEncodedToken) = ExtractCapturedConfirmEmailChangeLink(); + Assert.Equal(email, capturedCurrent); + Assert.Equal(newEmail, capturedNew); + + using var publicClient = Factory.CreateClientWithTenant(); + var confirmResponse = await publicClient.PostAsJsonAsync("/auth/confirm-email-change", new + { + Email = email, + NewEmail = newEmail, + Token = capturedEncodedToken + }); + + await confirmResponse.AssertSuccess(); + } + + /// + /// Sets up a fresh user with a known password and returns an authenticated client. + /// + private async Task<(string Email, string Password, HttpClient Client)> SetupAuthenticatedUserAsync() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var email = $"emailchange-{Guid.NewGuid():N}@example.com"; + var password = "InitialP@ss1!"; + + await RegisterAndSetPasswordAsync( + sysClient, + password, + email: email, + username: $"emailchange{Guid.NewGuid():N}", + role: IdmtDefaultRoleTypes.TenantAdmin); + + // Confirm the email so SignIn requireConfirmedEmail passes. + await ConfirmEmailDirectAsync(email); + + var loginClient = Factory.CreateClientWithTenant(); + var loginResponse = await loginClient.PostAsJsonAsync("/auth/token", new { Email = email, Password = password }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + loginClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + return (email, password, loginClient); + } + + private async Task ConfirmEmailDirectAsync(string email) + { + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant missing."); + + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email) + ?? throw new InvalidOperationException($"User '{email}' not found."); + if (!user.EmailConfirmed) + { + user.EmailConfirmed = true; + await userManager.UpdateAsync(user); + } + } + + private async Task GetPendingEmailAsync(string email) + { + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant missing."); + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email) + ?? throw new InvalidOperationException($"User '{email}' not found."); + return user.PendingEmail; + } + + private async Task GetEmailAsync(string email) + { + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant missing."); + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(email); + return user?.Email; + } + + /// + /// Looks up a user by either originalEmail or fallback (after a successful change). + /// + private async Task GetEmailByIdAsync(string idLookupEmail, string originalEmail, string fallback) + { + using var scope = Factory.Services.CreateScope(); + var provider = scope.ServiceProvider; + var store = provider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier) + ?? throw new InvalidOperationException("Default tenant missing."); + var setter = provider.GetRequiredService(); + setter.MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = provider.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(fallback) ?? await userManager.FindByEmailAsync(originalEmail); + return user?.Email; + } + + /// + /// Extracts (currentEmail, newEmail, encodedToken) from the most recent + /// SendConfirmationLinkAsync invocation captured by the EmailSenderMock. + /// + private (string CurrentEmail, string NewEmail, string EncodedToken) ExtractCapturedConfirmEmailChangeLink() + { + var invocation = Factory.EmailSenderMock.Invocations + .Where(i => i.Method.Name == nameof(IEmailSender.SendConfirmationLinkAsync)) + .LastOrDefault() + ?? throw new InvalidOperationException("No SendConfirmationLinkAsync invocation captured."); + + var link = (string)invocation.Arguments[2]; + var uri = new Uri(link); + var query = QueryHelpers.ParseQuery(uri.Query); + + return ( + query["email"].ToString(), + query["newEmail"].ToString(), + query["token"].ToString()); + } +} diff --git a/tests/Idmt.BasicSample.Tests/Migration/MigrationApplyTests.cs b/tests/Idmt.BasicSample.Tests/Migration/MigrationApplyTests.cs new file mode 100644 index 0000000..aab2fc6 --- /dev/null +++ b/tests/Idmt.BasicSample.Tests/Migration/MigrationApplyTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using System.Net.Http.Json; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Migration; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Idmt.BasicSample.Tests.Migration; + +/// +/// Step 11 / F42 integration tests for the canonical identity data migrator. +/// +/// +/// Residual deferrals (Step 11 plan §C / §D): +/// +/// F41 (Migration_StartsFromPhase0SchemaSnapshot) — deferred. +/// Plan called for a hand-written Phase 0 DDL snapshot (Phase0SchemaSnapshot.sql) to be +/// loaded into a SQLite fixture and validated by SHA-256. Deferred because the codebase has +/// already shipped Phase 1 model changes; regenerating the legacy DDL from commit +/// 59d31f0 requires more migration tooling than the harness warrants. Consumers +/// verify migration output against their own pre-migration schema; the plugin ships the +/// migration code, the snapshot artifact is consumer-side. +/// F47 (Migration_AuditEmission_ExactCount) — deferred. +/// Auditing during migration is exercised indirectly via the migrator unit tests (audit +/// rewrite happy path). Pinning an exact count requires fixturing the legacy seed shape +/// that F41 would produce; without F41 it is brittle. +/// +/// Honest scope here: F42 exercises the load-bearing security invariant — that running +/// the migrator's SecurityStamp rotation invalidates any bearer / refresh ticket minted +/// before migration. The test does NOT load Phase 0 DDL; it boots the live test factory +/// (Phase 1 schema), mints tokens via /auth/token, runs ApplyAsync against a +/// sibling DI container sharing the same SQLite connection, then asserts the pre-migration +/// refresh token is rejected at /auth/refresh. +/// +public sealed class MigrationApplyTests : BaseIntegrationTest +{ + public MigrationApplyTests(IdmtApiFactory factory) : base(factory) + { + } + + [Fact] + public async Task Migration_PreMigrationBearerToken_RejectedAfterStampRotation() + { + // Arrange: mint a bearer + refresh token via the live login flow. + var client = Factory.CreateClientWithTenant(); + var loginResponse = await client.PostAsJsonAsync("/auth/token", new + { + Email = IdmtApiFactory.SysAdminEmail, + Password = IdmtApiFactory.SysAdminPassword, + }); + Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(tokens); + + // Sanity: refresh works before migration runs. + var preRefresh = await client.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(tokens!.RefreshToken)); + Assert.Equal(HttpStatusCode.OK, preRefresh.StatusCode); + var refreshedTokens = await preRefresh.Content.ReadFromJsonAsync(); + Assert.NotNull(refreshedTokens); + + // Act: run the migrator against a sibling DI container that shares the same SQLite + // connection as the running test host. With no duplicates seeded the dry-run reports + // zero groups but ApplyAsync still rotates SecurityStamp on every surviving user — + // that rotation is the security invariant under test. + await RunMigrationAsync(); + + // Assert: the previously-refreshed bearer/refresh ticket is now invalid because its + // SecurityStamp claim no longer matches the user's row. + var postRefresh = await client.PostAsJsonAsync( + "/auth/refresh", + new RefreshToken.RefreshTokenRequest(refreshedTokens!.RefreshToken)); + + Assert.Equal(HttpStatusCode.Unauthorized, postRefresh.StatusCode); + } + + private async Task RunMigrationAsync() + { + var connection = Factory.SharedConnection + ?? throw new InvalidOperationException("Test factory is not initialised; SharedConnection unavailable."); + + // Build a sibling DI container scoped to the migrator. Sharing the same SQLite + // connection means writes here are visible to the live host's IdmtDbContext on the + // next read. + var services = new ServiceCollection(); + + var tenantAccessor = new Mock(); + var sentinelTenant = new IdmtTenantInfo( + id: IdmtApiFactory.DefaultTenantIdentifier, + identifier: IdmtApiFactory.DefaultTenantIdentifier, + name: "Migration Sentinel"); + tenantAccessor.SetupGet(x => x.MultiTenantContext) + .Returns(new MultiTenantContext(sentinelTenant)); + + services.AddSingleton(tenantAccessor.Object); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddLogging(); + services.AddDbContext(options => options.UseSqlite(connection)); + services.AddIdmtMigration(); + + await using var sp = services.BuildServiceProvider(); + + var migrator = sp.GetRequiredService(); + var dryRun = await migrator.DryRunAsync(); + await migrator.ApplyAsync(dryRun.Fingerprint, []); + } +} diff --git a/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index 2a00b28..9677b23 100644 --- a/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -7,7 +7,9 @@ using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Features.Manage; using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Idmt.BasicSample.Tests; @@ -25,11 +27,21 @@ public MultiTenancyIntegrationTests(IdmtApiFactory factory) : base(factory) { } private async Task EnsureTenantsExistAsync() { - using var scope = Factory.Services.CreateScope(); - var handler = scope.ServiceProvider.GetRequiredService(); - - await handler.HandleAsync(new CreateTenant.CreateTenantRequest(TenantA, TenantA)); - await handler.HandleAsync(new CreateTenant.CreateTenantRequest(TenantB, TenantB)); + // Phase 1 / Step 9: CreateTenantHandler requires authenticated invoker. Drive via HTTP. + var sysClient = await CreateAuthenticatedClientAsync(); + foreach (var tenantId in new[] { TenantA, TenantB }) + { + var response = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = tenantId, + Name = tenantId + }); + // Tenant may already exist (Conflict) — ignore that, fail otherwise. + if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.Conflict) + { + await response.AssertSuccess(); + } + } } private async Task CreateUserInTenantAsync(string tenantIdentifier, string email, string password, string role = IdmtDefaultRoleTypes.TenantAdmin) @@ -44,9 +56,25 @@ private async Task CreateUserInTenantAsync(string tenantIdentifier, string email setter.MultiTenantContext = new MultiTenantContext(tenant!); var userManager = provider.GetRequiredService>(); - var user = new IdmtUser { UserName = email, Email = email, TenantId = tenant!.Id, EmailConfirmed = true }; + // Phase 1: IdmtUser is global — no TenantId column. Tenant membership is granted + // via an explicit TenantAccess row. + var user = new IdmtUser { UserName = email, Email = email, EmailConfirmed = true }; await userManager.CreateAsync(user, password); await userManager.AddToRoleAsync(user, role); + + var dbContext = provider.GetRequiredService(); + var hasAccess = await dbContext.TenantAccess.AnyAsync(ta => ta.UserId == user.Id && ta.TenantId == tenant!.Id); + if (!hasAccess) + { + dbContext.TenantAccess.Add(new TenantAccess + { + UserId = user.Id, + TenantId = tenant!.Id, + IsActive = true, + ExpiresAt = null + }); + await dbContext.SaveChangesAsync(); + } } #region Login Isolation Tests @@ -193,7 +221,10 @@ public async Task User_in_other_tenant_cannot_access_protected_endpoint_for_curr // Create user in Tenant A var emailA = $"crosstoken-{Guid.NewGuid():N}@example.com"; var passwordA = "PasswordA1!"; - await CreateUserInTenantAsync(TenantA, emailA, passwordA, IdmtDefaultRoleTypes.SysSupport); + // Phase 1 / Step 9: SysSupport is no longer seeded as a per-tenant IdentityRole. Use + // TenantAdmin (still seeded per-tenant by CreateTenant) — the role choice is irrelevant + // to the cross-tenant token rejection assertion. + await CreateUserInTenantAsync(TenantA, emailA, passwordA, IdmtDefaultRoleTypes.TenantAdmin); // Login as Tenant A user var clientA = Factory.CreateClientWithTenant(TenantA); @@ -332,7 +363,7 @@ public async Task Complete_user_lifecycle_flow_across_tenants() using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( "/auth/reset-password", - new { TenantIdentifier = TenantA, Email = emailA, Token = EncodeToken(setupToken), NewPassword = setupPassword }); + new { Email = emailA, Token = EncodeToken(setupToken), NewPassword = setupPassword }); await resetResponse.AssertSuccess(); // 3. Login in Tenant A (Success) diff --git a/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs index 7b28b31..34ff0b1 100644 --- a/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs @@ -4,7 +4,12 @@ using Idmt.Plugin.Errors; using Idmt.Plugin.Features.Admin; using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; using Idmt.Plugin.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; @@ -15,18 +20,23 @@ public class CreateTenantHandlerTests { private readonly Mock> _tenantStoreMock; private readonly Mock _tenantOpsMock; + private readonly Mock _currentUserServiceMock; private readonly IOptions _options; + private readonly Guid _invokerUserId = Guid.NewGuid(); private readonly CreateTenant.CreateTenantHandler _handler; public CreateTenantHandlerTests() { _tenantStoreMock = new Mock>(); _tenantOpsMock = new Mock(); + _currentUserServiceMock = new Mock(); + _currentUserServiceMock.SetupGet(x => x.UserId).Returns(_invokerUserId); _options = Options.Create(new IdmtOptions()); _handler = new CreateTenant.CreateTenantHandler( _tenantStoreMock.Object, _tenantOpsMock.Object, + _currentUserServiceMock.Object, _options, NullLogger.Instance); } @@ -169,6 +179,7 @@ public async Task SeedsExtraRoles_WhenConfiguredInOptions() var handler = new CreateTenant.CreateTenantHandler( _tenantStoreMock.Object, _tenantOpsMock.Object, + _currentUserServiceMock.Object, optionsWithExtraRoles, NullLogger.Instance); @@ -218,4 +229,168 @@ public async Task SeedsExtraRoles_WhenConfiguredInOptions() It.IsAny()), Times.Once); } + + [Fact] + public async Task Handle_NullCurrentUser_ReturnsUnauthorized() + { + // Arrange + var unauthMock = new Mock(); + unauthMock.SetupGet(x => x.UserId).Returns((Guid?)null); + + var handler = new CreateTenant.CreateTenantHandler( + _tenantStoreMock.Object, + _tenantOpsMock.Object, + unauthMock.Object, + _options, + NullLogger.Instance); + + var request = new CreateTenant.CreateTenantRequest("new-tenant", "New Tenant"); + + // Act + var result = await handler.HandleAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.Equal("Auth.Unauthorized", result.FirstError.Code); + + // Tenant store must not be touched when invoker is unauthenticated. + _tenantStoreMock.Verify(x => x.GetByIdentifierAsync(It.IsAny()), Times.Never); + _tenantStoreMock.Verify(x => x.AddAsync(It.IsAny()), Times.Never); + _tenantOpsMock.Verify( + x => x.ExecuteInTenantScopeAsync( + It.IsAny(), + It.IsAny>>>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Handle_AsSysAdmin_CapturesInvokerUserIdOutsideInnerScope() + { + // V2-CRIT-2 regression. Asserts that invokerUserId resolved at handler entry is the value + // ultimately surfaced — and that ICurrentUserService.UserId is read EXACTLY ONCE (outer + // scope), not again from inside ExecuteInTenantScopeAsync. + + // Arrange + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync("new-tenant")) + .ReturnsAsync((IdmtTenantInfo?)null); + + _tenantStoreMock + .Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync(true); + + SetupRoleSeedSuccess(); + + var request = new CreateTenant.CreateTenantRequest("new-tenant", "New Tenant"); + + // Act + var result = await _handler.HandleAsync(request); + + // Assert + Assert.False(result.IsError); + + // Single read of UserId — proves we did not re-read from inside the inner scope. + _currentUserServiceMock.Verify(x => x.UserId, Times.Once); + } + + [Fact] + public void Handler_Constructor_DependsOnICurrentUserService() + { + // H1 regression: ctor-level test fails when ICurrentUserService dependency is removed. + var ctors = typeof(CreateTenant.CreateTenantHandler).GetConstructors(); + Assert.Single(ctors); + var ctor = ctors[0]; + var paramTypes = ctor.GetParameters().Select(p => p.ParameterType).ToArray(); + Assert.Contains(typeof(ICurrentUserService), paramTypes); + } + + [Fact] + public async Task Handle_RoleSeedingFails_RollsBackTenantAccess() + { + // V2-CRIT-1 regression: when role seeding fails mid-bootstrap, the ambient transaction + // must roll back so no TenantAccess row persists. We capture the inner-scope callback the + // handler hands to ITenantOperationService and execute it against a real SQLite-backed + // IdmtDbContext + a RoleManager mock that fails on CreateAsync. + using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var tenant = new IdmtTenantInfo("tenant-id-1", "new-tenant", "New Tenant"); + + var tenantAccessorMock = new Mock(); + tenantAccessorMock + .SetupGet(x => x.MultiTenantContext) + .Returns(new MultiTenantContext(tenant)); + + var currentUserStub = new Mock(); + currentUserStub.SetupGet(x => x.UserId).Returns(_invokerUserId); + + await using var dbContext = new IdmtDbContext( + tenantAccessorMock.Object, + dbOptions, + currentUserStub.Object, + TimeProvider.System, + NullLogger.Instance); + + await dbContext.Database.EnsureCreatedAsync(); + + // Failing RoleManager: every CreateAsync returns IdentityResult.Failed. + var roleStoreMock = Mock.Of>(); + var roleManagerMock = new Mock>( + roleStoreMock, null!, null!, null!, null!); + roleManagerMock.Setup(x => x.RoleExistsAsync(It.IsAny())).ReturnsAsync(false); + roleManagerMock.Setup(x => x.CreateAsync(It.IsAny())) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "boom" })); + + // Inner-scope provider exposes IdmtDbContext + RoleManager exactly as the production scope does. + var innerServices = new ServiceCollection(); + innerServices.AddSingleton(dbContext); + innerServices.AddSingleton(roleManagerMock.Object); + await using var innerProvider = innerServices.BuildServiceProvider(); + + // Capture the inner-scope operation the handler hands to ITenantOperationService and + // invoke it against our real DbContext + failing role manager. + Func>>? capturedOperation = null; + _tenantOpsMock + .Setup(x => x.ExecuteInTenantScopeAsync( + It.IsAny(), + It.IsAny>>>(), + It.IsAny())) + .Returns(async (string _id, Func>> op, bool _flag) => + { + capturedOperation = op; + return await op(innerProvider); + }); + + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync("new-tenant")) + .ReturnsAsync((IdmtTenantInfo?)null); + _tenantStoreMock + .Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync(true); + + var request = new CreateTenant.CreateTenantRequest("new-tenant", "New Tenant"); + + // Act + var result = await _handler.HandleAsync(request); + + // Assert: handler surfaces RoleSeedFailed and the transaction rolled back, so no + // TenantAccess row persisted in the SQLite-backed DbContext. + Assert.True(result.IsError); + Assert.Equal("Tenant.RoleSeedFailed", result.FirstError.Code); + + await using var verifyContext = new IdmtDbContext( + tenantAccessorMock.Object, + dbOptions, + currentUserStub.Object, + TimeProvider.System, + NullLogger.Instance); + + var tenantAccessRows = await verifyContext.TenantAccess.IgnoreQueryFilters().ToListAsync(); + Assert.Empty(tenantAccessRows); + } } diff --git a/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs index 33dc9f7..96f1c83 100644 --- a/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs @@ -13,23 +13,29 @@ namespace Idmt.UnitTests.Features.Admin; +/// +/// Unit tests for the Phase 1 (canonical identity) . +/// Asserts the handler writes ONLY a TenantAccess row in a single SaveChangesAsync — no shadow IdmtUser +/// creation, no ExecuteInTenantScopeAsync hop, no compensation. +/// public class GrantTenantAccessHandlerTests : IDisposable { - private readonly Mock _tenantOpsMock; private readonly FakeTimeProvider _timeProvider; private readonly IdmtDbContext _dbContext; private readonly Mock> _tenantStoreMock; private readonly Mock> _userManagerMock; + private readonly Mock _currentUserServiceMock; + private readonly Guid _callerUserId; private readonly GrantTenantAccess.GrantTenantAccessHandler _handler; public GrantTenantAccessHandlerTests() { - _tenantOpsMock = new Mock(); _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 3, 4, 12, 0, 0, TimeSpan.Zero)); - // Set up InMemory DbContext var tenantAccessorMock = new Mock(); - var currentUserServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); + _callerUserId = Guid.NewGuid(); + _currentUserServiceMock.SetupGet(x => x.UserId).Returns(_callerUserId); var dummyTenant = new IdmtTenantInfo("sys-id", "system-test", "System Test"); var dummyContext = new MultiTenantContext(dummyTenant); tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(dummyContext); @@ -41,7 +47,7 @@ public GrantTenantAccessHandlerTests() _dbContext = new IdmtDbContext( tenantAccessorMock.Object, dbOptions, - currentUserServiceMock.Object, + _currentUserServiceMock.Object, TimeProvider.System, NullLogger.Instance); @@ -51,26 +57,51 @@ public GrantTenantAccessHandlerTests() _userManagerMock = new Mock>( userStoreMock.Object, null!, null!, null!, null!, null!, null!, null!, null!); - // Issue 19 fix: inject dependencies directly — no IServiceProvider wrapper required. _handler = new GrantTenantAccess.GrantTenantAccessHandler( _dbContext, _userManagerMock.Object, _tenantStoreMock.Object, - _tenantOpsMock.Object, + _currentUserServiceMock.Object, _timeProvider, NullLogger.Instance); } + private void StubFindUser(IdmtUser user) + { + _userManagerMock + .Setup(x => x.FindByIdAsync(user.Id.ToString())) + .ReturnsAsync(user); + } + + private void StubFindUser_NotFound(Guid userId) + { + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((IdmtUser?)null); + } + + private void StubTenant(string identifier, string id, bool isActive) + { + var t = new IdmtTenantInfo(id, identifier, identifier) { IsActive = isActive }; + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(identifier)) + .ReturnsAsync(t); + } + + private void StubTenant_NotFound(string identifier) + { + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(identifier)) + .ReturnsAsync((IdmtTenantInfo?)null); + } + [Fact] public async Task ReturnsValidationError_WhenExpiresAtIsInPast() { - // Arrange - time is 2026-03-04 12:00 UTC; expiration is yesterday var pastDate = new DateTimeOffset(2026, 3, 3, 0, 0, 0, TimeSpan.Zero); - // Act var result = await _handler.HandleAsync(Guid.NewGuid(), "some-tenant", pastDate); - // Assert Assert.True(result.IsError); Assert.Equal(ErrorType.Validation, result.FirstError.Type); Assert.Equal("ExpiresAt", result.FirstError.Code); @@ -79,156 +110,156 @@ public async Task ReturnsValidationError_WhenExpiresAtIsInPast() [Fact] public async Task ReturnsValidationError_WhenExpiresAtEqualsNow() { - // Arrange - exactly the current time (boundary: <= means equal is rejected) var exactNow = _timeProvider.GetUtcNow(); - // Act var result = await _handler.HandleAsync(Guid.NewGuid(), "some-tenant", exactNow); - // Assert Assert.True(result.IsError); Assert.Equal(ErrorType.Validation, result.FirstError.Type); Assert.Equal("ExpiresAt", result.FirstError.Code); } [Fact] - public async Task ReturnsUserNotFound_WhenUserDoesNotExist() + public async Task Handle_NullCurrentUser_ReturnsUnauthorized() + { + _currentUserServiceMock.SetupGet(x => x.UserId).Returns((Guid?)null); + + var result = await _handler.HandleAsync(Guid.NewGuid(), "some-tenant"); + + Assert.True(result.IsError); + Assert.Equal("Auth.Unauthorized", result.FirstError.Code); + } + + [Fact] + public async Task Handle_SelfTarget_ReturnsForbidden() + { + var result = await _handler.HandleAsync(_callerUserId, "some-tenant"); + + Assert.True(result.IsError); + Assert.Equal("General.SelfTarget", result.FirstError.Code); + } + + [Fact] + public async Task Handle_NonExistentUser_ReturnsUserNotFound() { - // Arrange - no user in DbContext var nonExistentUserId = Guid.NewGuid(); + StubFindUser_NotFound(nonExistentUserId); - // Act var result = await _handler.HandleAsync(nonExistentUserId, "some-tenant"); - // Assert Assert.True(result.IsError); Assert.Equal("User.NotFound", result.FirstError.Code); } [Fact] - public async Task ReturnsTenantInactive_WhenTargetTenantIsInactive() + public async Task Handle_NonExistentTenant_ReturnsTenantNotFound() { - // Arrange - var userId = Guid.NewGuid(); - _dbContext.Users.Add(new IdmtUser - { - Id = userId, - UserName = "testuser", - Email = "test@test.com", - TenantId = "sys-id" - }); - await _dbContext.SaveChangesAsync(); + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "u", Email = "u@test.com" }; + StubFindUser(user); + StubTenant_NotFound("nope-tenant"); - var inactiveTenant = new IdmtTenantInfo("tid", "inactive-tenant", "Inactive") { IsActive = false }; - _tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("inactive-tenant")) - .ReturnsAsync(inactiveTenant); + var result = await _handler.HandleAsync(user.Id, "nope-tenant"); - // Act - var result = await _handler.HandleAsync(userId, "inactive-tenant"); + Assert.True(result.IsError); + Assert.Equal("Tenant.NotFound", result.FirstError.Code); + } + + [Fact] + public async Task Handle_InactiveTenant_ReturnsTenantInactive() + { + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "u", Email = "u@test.com" }; + StubFindUser(user); + StubTenant("inactive-tenant", "tid-inactive", isActive: false); + + var result = await _handler.HandleAsync(user.Id, "inactive-tenant"); - // Assert Assert.True(result.IsError); Assert.Equal("Tenant.Inactive", result.FirstError.Code); } [Fact] - public async Task ReturnsNoRolesAssigned_WhenUserHasNoRoles() + public async Task Handle_NewGrant_InsertsTenantAccessRow_NoUserCreation() { // Arrange - var userId = Guid.NewGuid(); - _dbContext.Users.Add(new IdmtUser - { - Id = userId, - UserName = "noroles", - Email = "noroles@test.com", - TenantId = "sys-id" - }); + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "newgrant", Email = "newgrant@test.com" }; + // Persist canonically so dbContext.Users count baseline is 1 — handler must not increment it. + _dbContext.Users.Add(user); await _dbContext.SaveChangesAsync(); - var activeTenant = new IdmtTenantInfo("tid", "active-tenant", "Active") { IsActive = true }; - _tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("active-tenant")) - .ReturnsAsync(activeTenant); + StubFindUser(user); + StubTenant("target-tenant", "tid-new", isActive: true); - _userManagerMock - .Setup(x => x.GetRolesAsync(It.IsAny())) - .ReturnsAsync(new List()); + var beforeUsers = await _dbContext.Users.CountAsync(); + var beforeTenantAccess = await _dbContext.TenantAccess.CountAsync(); // Act - var result = await _handler.HandleAsync(userId, "active-tenant"); + var result = await _handler.HandleAsync(user.Id, "target-tenant"); // Assert - Assert.True(result.IsError); - Assert.Equal("User.NoRolesAssigned", result.FirstError.Code); + Assert.False(result.IsError); + Assert.Equal(beforeUsers, await _dbContext.Users.CountAsync()); + Assert.Equal(beforeTenantAccess + 1, await _dbContext.TenantAccess.CountAsync()); + + var ta = await _dbContext.TenantAccess + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.TenantId == "tid-new"); + Assert.NotNull(ta); + Assert.True(ta!.IsActive); + Assert.Null(ta.ExpiresAt); + + // Belt-and-braces: no shadow user creation should ever invoke UserManager.CreateAsync. + _userManagerMock.Verify( + x => x.CreateAsync(It.IsAny()), + Times.Never); + _userManagerMock.Verify( + x => x.CreateAsync(It.IsAny(), It.IsAny()), + Times.Never); } [Fact] - public async Task ReactivatesExistingAccess_WhenRecordAlreadyExists() + public async Task Handle_ExistingGrant_UpdatesIsActiveAndExpiresAt() { // Arrange - var userId = Guid.NewGuid(); - var tenantId = "target-tid"; - - _dbContext.Users.Add(new IdmtUser - { - Id = userId, - UserName = "existinguser", - Email = "existing@test.com", - TenantId = "sys-id" - }); - - // Pre-existing inactive access record + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "existing", Email = "existing@test.com" }; + _dbContext.Users.Add(user); _dbContext.TenantAccess.Add(new TenantAccess { - UserId = userId, - TenantId = tenantId, + UserId = user.Id, + TenantId = "tid-existing", IsActive = false, ExpiresAt = null }); await _dbContext.SaveChangesAsync(); - var activeTenant = new IdmtTenantInfo(tenantId, "target-tenant", "Target") { IsActive = true }; - _tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("target-tenant")) - .ReturnsAsync(activeTenant); - - _userManagerMock - .Setup(x => x.GetRolesAsync(It.IsAny())) - .ReturnsAsync(new List { "SysAdmin" }); + StubFindUser(user); + StubTenant("target-tenant", "tid-existing", isActive: true); var futureExpiry = new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero); - _tenantOpsMock - .Setup(x => x.ExecuteInTenantScopeAsync( - "target-tenant", - It.IsAny>>>(), - It.IsAny())) - .ReturnsAsync(Result.Success); - // Act - var result = await _handler.HandleAsync(userId, "target-tenant", futureExpiry); + var result = await _handler.HandleAsync(user.Id, "target-tenant", futureExpiry); // Assert Assert.False(result.IsError); - // Verify the access record was reactivated with new expiry - var access = await _dbContext.TenantAccess - .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == tenantId); + var ta = await _dbContext.TenantAccess + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.TenantId == "tid-existing"); + Assert.NotNull(ta); + Assert.True(ta!.IsActive); + Assert.Equal(futureExpiry, ta.ExpiresAt); - Assert.NotNull(access); - Assert.True(access.IsActive); - Assert.Equal(futureExpiry, access.ExpiresAt); + // Single row only — no duplicate insert. + var rowCount = await _dbContext.TenantAccess + .CountAsync(x => x.UserId == user.Id && x.TenantId == "tid-existing"); + Assert.Equal(1, rowCount); } [Fact] - public async Task ReturnsAccessError_AndExecutesCompensatingAction_WhenSaveChangesFails() + public async Task Handle_AtomicityWhenSaveChangesThrows_NoPartialState() { - // Arrange — build a completely separate handler whose DbContext throws on SaveChangesAsync. - // We share an InMemory database name so the seed context and the throwing context see the same data. - + // Arrange — share an InMemory DB name so the throwing context sees the same data the seed context wrote. var tenantAccessorMock = new Mock(); var currentUserServiceMock = new Mock(); + currentUserServiceMock.SetupGet(x => x.UserId).Returns(Guid.NewGuid()); var dummyTenant = new IdmtTenantInfo("sys-id", "system-test", "System Test"); var dummyContext = new MultiTenantContext(dummyTenant); tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(dummyContext); @@ -238,24 +269,16 @@ public async Task ReturnsAccessError_AndExecutesCompensatingAction_WhenSaveChang .UseInMemoryDatabase(databaseName: sharedDbName) .Options; - // Seed a user in a normal (non-throwing) context - var userId = Guid.NewGuid(); + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "atomic", Email = "atomic@test.com" }; using (var seedContext = new IdmtDbContext( tenantAccessorMock.Object, dbOptions, currentUserServiceMock.Object, TimeProvider.System, NullLogger.Instance)) { - seedContext.Users.Add(new IdmtUser - { - Id = userId, - UserName = "compuser", - Email = "comp@test.com", - TenantId = "sys-id" - }); + seedContext.Users.Add(user); await seedContext.SaveChangesAsync(); } - // Create the throwing DbContext that shares the same InMemory database var throwingContext = new ThrowOnSaveDbContext( tenantAccessorMock.Object, new DbContextOptionsBuilder() @@ -265,55 +288,42 @@ public async Task ReturnsAccessError_AndExecutesCompensatingAction_WhenSaveChang TimeProvider.System, NullLogger.Instance); - // Set up mocks var tenantStoreMock = new Mock>(); - var activeTenant = new IdmtTenantInfo("tid", "comp-tenant", "CompTenant") { IsActive = true }; tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("comp-tenant")) - .ReturnsAsync(activeTenant); + .Setup(x => x.GetByIdentifierAsync("atomic-tenant")) + .ReturnsAsync(new IdmtTenantInfo("tid-atomic", "atomic-tenant", "Atomic") { IsActive = true }); var userStoreMock = new Mock>(); var userManagerMock = new Mock>( userStoreMock.Object, null!, null!, null!, null!, null!, null!, null!, null!); userManagerMock - .Setup(x => x.GetRolesAsync(It.IsAny())) - .ReturnsAsync(new List { "SysAdmin" }); - - var tenantOpsMock = new Mock(); - - // Both calls to ExecuteInTenantScopeAsync return Success: - // 1st call — tenant-scope user creation - // 2nd call — compensating action after SaveChanges failure - tenantOpsMock - .Setup(x => x.ExecuteInTenantScopeAsync( - "comp-tenant", - It.IsAny>>>(), - It.IsAny())) - .ReturnsAsync(Result.Success); - - // Issue 19 fix: inject throwing context and mocks directly — no IServiceProvider wrapper. + .Setup(x => x.FindByIdAsync(user.Id.ToString())) + .ReturnsAsync(user); + var handler = new GrantTenantAccess.GrantTenantAccessHandler( throwingContext, userManagerMock.Object, tenantStoreMock.Object, - tenantOpsMock.Object, + currentUserServiceMock.Object, _timeProvider, NullLogger.Instance); // Act - var result = await handler.HandleAsync(userId, "comp-tenant"); + var result = await handler.HandleAsync(user.Id, "atomic-tenant"); - // Assert — handler should return Tenant.AccessError after the compensating action + // Assert — handler returns Tenant.AccessError; no TenantAccess row persists. Assert.True(result.IsError); Assert.Equal("Tenant.AccessError", result.FirstError.Code); - // Verify the compensating action was invoked (2 total calls: tenant user creation + compensation) - tenantOpsMock.Verify( - x => x.ExecuteInTenantScopeAsync( - "comp-tenant", - It.IsAny>>>(), - It.IsAny()), - Times.Exactly(2)); + using (var verifyContext = new IdmtDbContext( + tenantAccessorMock.Object, dbOptions, + currentUserServiceMock.Object, TimeProvider.System, + NullLogger.Instance)) + { + var anyTenantAccess = await verifyContext.TenantAccess + .AnyAsync(x => x.UserId == user.Id && x.TenantId == "tid-atomic"); + Assert.False(anyTenantAccess); + } } public void Dispose() @@ -324,7 +334,7 @@ public void Dispose() /// /// A test-only subclass whose SaveChangesAsync always /// throws a , simulating a persistence failure so we can - /// verify the handler's compensating action fires. + /// assert atomicity (no partial state, error returned). /// private sealed class ThrowOnSaveDbContext : IdmtDbContext { diff --git a/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs index 8685c7c..2a311f9 100644 --- a/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs @@ -4,28 +4,38 @@ using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; using Idmt.Plugin.Services; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; namespace Idmt.UnitTests.Features.Admin; +/// +/// Unit tests for the Phase 1 (canonical identity) . +/// Asserts the handler flips TenantAccess.IsActive = false in a single SaveChangesAsync, then +/// revokes outstanding bearer tokens by canonical UserId. No shadow IdmtUser deactivation, no +/// ExecuteInTenantScopeAsync hop. +/// public class RevokeTenantAccessHandlerTests : IDisposable { - private readonly Mock _tenantOpsMock; private readonly Mock _tokenRevocationServiceMock; private readonly IdmtDbContext _dbContext; private readonly Mock> _tenantStoreMock; + private readonly Mock> _userManagerMock; + private readonly Mock _currentUserServiceMock; + private readonly Guid _callerUserId; private readonly RevokeTenantAccess.RevokeTenantAccessHandler _handler; public RevokeTenantAccessHandlerTests() { - _tenantOpsMock = new Mock(); _tokenRevocationServiceMock = new Mock(); - // InMemory DbContext var tenantAccessorMock = new Mock(); - var currentUserServiceMock = new Mock(); + _currentUserServiceMock = new Mock(); + _callerUserId = Guid.NewGuid(); + _currentUserServiceMock.SetupGet(x => x.UserId).Returns(_callerUserId); var dummyTenant = new IdmtTenantInfo("sys-id", "system-test", "System Test"); var dummyContext = new MultiTenantContext(dummyTenant); tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(dummyContext); @@ -37,103 +47,298 @@ public RevokeTenantAccessHandlerTests() _dbContext = new IdmtDbContext( tenantAccessorMock.Object, dbOptions, - currentUserServiceMock.Object, + _currentUserServiceMock.Object, TimeProvider.System, NullLogger.Instance); _tenantStoreMock = new Mock>(); + var userStoreMock = new Mock>(); + _userManagerMock = new Mock>( + userStoreMock.Object, null!, null!, null!, null!, null!, null!, null!, null!); + _handler = new RevokeTenantAccess.RevokeTenantAccessHandler( _dbContext, + _userManagerMock.Object, _tenantStoreMock.Object, - _tenantOpsMock.Object, _tokenRevocationServiceMock.Object, + _currentUserServiceMock.Object, NullLogger.Instance); } - [Fact] - public async Task ReturnsAccessNotFound_WhenNoAccessRecord() + private void StubFindUser(IdmtUser user) { - // Arrange - var userId = Guid.NewGuid(); + _userManagerMock + .Setup(x => x.FindByIdAsync(user.Id.ToString())) + .ReturnsAsync(user); + } - _dbContext.Users.Add(new IdmtUser - { - Id = userId, - UserName = "testuser", - Email = "test@test.com", - TenantId = "sys-id" - }); - await _dbContext.SaveChangesAsync(); + private void StubFindUser_NotFound(Guid userId) + { + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((IdmtUser?)null); + } - var tenant = new IdmtTenantInfo("tid", "target-tenant", "Target"); + private void StubTenant(string identifier, string id) + { + var t = new IdmtTenantInfo(id, identifier, identifier) { IsActive = true }; _tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("target-tenant")) - .ReturnsAsync(tenant); + .Setup(x => x.GetByIdentifierAsync(identifier)) + .ReturnsAsync(t); + } - // No access record seeded + private void StubTenant_NotFound(string identifier) + { + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(identifier)) + .ReturnsAsync((IdmtTenantInfo?)null); + } - // Act - var result = await _handler.HandleAsync(userId, "target-tenant"); + [Fact] + public async Task Handle_NullCurrentUser_ReturnsUnauthorized() + { + _currentUserServiceMock.SetupGet(x => x.UserId).Returns((Guid?)null); + + var result = await _handler.HandleAsync(Guid.NewGuid(), "some-tenant"); + + Assert.True(result.IsError); + Assert.Equal("Auth.Unauthorized", result.FirstError.Code); + } + + [Fact] + public async Task Handle_SelfTarget_ReturnsForbidden() + { + var result = await _handler.HandleAsync(_callerUserId, "some-tenant"); + + Assert.True(result.IsError); + Assert.Equal("General.SelfTarget", result.FirstError.Code); + } + + [Fact] + public async Task Handle_NonExistentUser_ReturnsUserNotFound() + { + var nonExistentUserId = Guid.NewGuid(); + StubFindUser_NotFound(nonExistentUserId); + + var result = await _handler.HandleAsync(nonExistentUserId, "some-tenant"); + + Assert.True(result.IsError); + Assert.Equal("User.NotFound", result.FirstError.Code); + } + + [Fact] + public async Task Handle_NonExistentTenant_ReturnsTenantNotFound() + { + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "u", Email = "u@test.com" }; + StubFindUser(user); + StubTenant_NotFound("nope-tenant"); + + var result = await _handler.HandleAsync(user.Id, "nope-tenant"); + + Assert.True(result.IsError); + Assert.Equal("Tenant.NotFound", result.FirstError.Code); + } + + [Fact] + public async Task Handle_NoExistingAccess_ReturnsAccessNotFound() + { + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "u", Email = "u@test.com" }; + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + + StubFindUser(user); + StubTenant("target-tenant", "tid-no-access"); + + var result = await _handler.HandleAsync(user.Id, "target-tenant"); - // Assert Assert.True(result.IsError); Assert.Equal("Tenant.AccessNotFound", result.FirstError.Code); + + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); } [Fact] - public async Task SucceedsGracefully_WhenUserNotInTenantScope() + public async Task Handle_ActiveAccess_FlipsIsActiveFalse_AndRevokesTokens() { // Arrange - var userId = Guid.NewGuid(); - var tenantId = "tid"; - - _dbContext.Users.Add(new IdmtUser + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "active", Email = "active@test.com" }; + _dbContext.Users.Add(user); + _dbContext.TenantAccess.Add(new TenantAccess { - Id = userId, - UserName = "scopeuser", - Email = "scope@test.com", - TenantId = "sys-id" + UserId = user.Id, + TenantId = "tid-active", + IsActive = true }); + await _dbContext.SaveChangesAsync(); + + StubFindUser(user); + StubTenant("target-tenant", "tid-active"); + + // Act + var result = await _handler.HandleAsync(user.Id, "target-tenant"); + + // Assert + Assert.False(result.IsError); + + var ta = await _dbContext.TenantAccess + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.TenantId == "tid-active"); + Assert.NotNull(ta); + Assert.False(ta!.IsActive); + + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(user.Id, "tid-active", It.IsAny()), + Times.Once); + } + [Fact] + public async Task Handle_RevokeUserTokensCalledWithCanonicalUserId() + { + // Regression for N1: token revocation must key on canonical user.Id, never a shadow id. + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "canonical", Email = "canonical@test.com" }; + _dbContext.Users.Add(user); _dbContext.TenantAccess.Add(new TenantAccess { - UserId = userId, - TenantId = tenantId, + UserId = user.Id, + TenantId = "tid-canonical", IsActive = true }); await _dbContext.SaveChangesAsync(); - var tenant = new IdmtTenantInfo(tenantId, "target-tenant", "Target"); - _tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("target-tenant")) - .ReturnsAsync(tenant); - - // ExecuteInTenantScopeAsync succeeds (user not found in tenant scope is handled gracefully - // in the handler by returning Result.Success when targetUser is null) - _tenantOpsMock - .Setup(x => x.ExecuteInTenantScopeAsync( - "target-tenant", - It.IsAny>>>(), - false)) - .ReturnsAsync(Result.Success); + StubFindUser(user); + StubTenant("target-tenant", "tid-canonical"); - // Act - var result = await _handler.HandleAsync(userId, "target-tenant"); + Guid? capturedUserId = null; + string? capturedTenantId = null; + _tokenRevocationServiceMock + .Setup(x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((uid, tid, _) => + { + capturedUserId = uid; + capturedTenantId = tid; + }) + .Returns(Task.CompletedTask); + + var result = await _handler.HandleAsync(user.Id, "target-tenant"); - // Assert Assert.False(result.IsError); + Assert.Equal(user.Id, capturedUserId); + Assert.Equal("tid-canonical", capturedTenantId); + } - // Verify the access record was deactivated - var access = await _dbContext.TenantAccess - .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == tenantId); + [Fact] + public async Task Handle_AtomicityWhenSaveChangesThrows_NoStateChange_NoTokenRevocation() + { + // Arrange — share an InMemory DB name so the throwing context observes seed data. + var tenantAccessorMock = new Mock(); + var currentUserServiceMock = new Mock(); + currentUserServiceMock.SetupGet(x => x.UserId).Returns(Guid.NewGuid()); + var dummyTenant = new IdmtTenantInfo("sys-id", "system-test", "System Test"); + var dummyContext = new MultiTenantContext(dummyTenant); + tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(dummyContext); - Assert.NotNull(access); - Assert.False(access.IsActive); + var sharedDbName = Guid.NewGuid().ToString(); + var dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: sharedDbName) + .Options; + + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "atomic-rev", Email = "atomic-rev@test.com" }; + using (var seedContext = new IdmtDbContext( + tenantAccessorMock.Object, dbOptions, + currentUserServiceMock.Object, TimeProvider.System, + NullLogger.Instance)) + { + seedContext.Users.Add(user); + seedContext.TenantAccess.Add(new TenantAccess + { + UserId = user.Id, + TenantId = "tid-atomic-rev", + IsActive = true + }); + await seedContext.SaveChangesAsync(); + } + + var throwingContext = new ThrowOnSaveDbContext( + tenantAccessorMock.Object, + new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: sharedDbName) + .Options, + currentUserServiceMock.Object, + TimeProvider.System, + NullLogger.Instance); + + var tenantStoreMock = new Mock>(); + tenantStoreMock + .Setup(x => x.GetByIdentifierAsync("atomic-rev-tenant")) + .ReturnsAsync(new IdmtTenantInfo("tid-atomic-rev", "atomic-rev-tenant", "Atomic Rev") { IsActive = true }); + + var userStoreMock = new Mock>(); + var userManagerMock = new Mock>( + userStoreMock.Object, null!, null!, null!, null!, null!, null!, null!, null!); + userManagerMock + .Setup(x => x.FindByIdAsync(user.Id.ToString())) + .ReturnsAsync(user); + + var tokenRevMock = new Mock(); + + var handler = new RevokeTenantAccess.RevokeTenantAccessHandler( + throwingContext, + userManagerMock.Object, + tenantStoreMock.Object, + tokenRevMock.Object, + currentUserServiceMock.Object, + NullLogger.Instance); + + // Act + var result = await handler.HandleAsync(user.Id, "atomic-rev-tenant"); + + // Assert — handler returns Tenant.AccessError; no IsActive flip persists; no token revocation called. + Assert.True(result.IsError); + Assert.Equal("Tenant.AccessError", result.FirstError.Code); + + using (var verifyContext = new IdmtDbContext( + tenantAccessorMock.Object, dbOptions, + currentUserServiceMock.Object, TimeProvider.System, + NullLogger.Instance)) + { + var ta = await verifyContext.TenantAccess + .FirstOrDefaultAsync(x => x.UserId == user.Id && x.TenantId == "tid-atomic-rev"); + Assert.NotNull(ta); + Assert.True(ta!.IsActive); + } + + tokenRevMock.Verify( + x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); } public void Dispose() { _dbContext.Dispose(); } + + /// + /// A test-only subclass whose SaveChangesAsync always + /// throws a , simulating a persistence failure so we can + /// assert atomicity (no state change, error returned, no downstream side-effects). + /// + private sealed class ThrowOnSaveDbContext : IdmtDbContext + { + public ThrowOnSaveDbContext( + IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + ICurrentUserService currentUserService, + TimeProvider timeProvider, + ILogger logger) + : base(multiTenantContextAccessor, options, currentUserService, timeProvider, logger) + { + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + throw new DbUpdateException("Simulated save failure"); + } + } } diff --git a/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailChangeHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailChangeHandlerTests.cs new file mode 100644 index 0000000..0910398 --- /dev/null +++ b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailChangeHandlerTests.cs @@ -0,0 +1,200 @@ +using ErrorOr; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Idmt.UnitTests.Features.Auth; + +public class ConfirmEmailChangeHandlerTests : IDisposable +{ + private readonly Mock> _userManagerMock; + private readonly IdmtDbContext _dbContext; + private readonly ConfirmEmailChange.ConfirmEmailChangeHandler _handler; + + public ConfirmEmailChangeHandlerTests() + { + var userStoreMock = new Mock>(); + _userManagerMock = new Mock>( + userStoreMock.Object, null!, null!, null!, null!, null!, null!, null!, null!); + + var tenantAccessorMock = new Mock(); + var dummyTenant = new IdmtTenantInfo("system-test-tenant", "system-test", "System Test Tenant"); + var dummyContext = new MultiTenantContext(dummyTenant); + tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(dummyContext); + + var currentUserServiceMock = new Mock(); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + _dbContext = new IdmtDbContext( + tenantAccessorMock.Object, + options, + currentUserServiceMock.Object, + TimeProvider.System, + NullLogger.Instance); + + _handler = new ConfirmEmailChange.ConfirmEmailChangeHandler( + _userManagerMock.Object, + _dbContext, + NullLogger.Instance); + } + + [Fact] + public async Task Handle_ValidToken_CommitsEmail_AndClearsPendingEmail() + { + // Arrange + var user = await SeedUserAsync("old@test.com", "user", pendingEmail: "new@test.com"); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", "valid-token")) + .ReturnsAsync(IdentityResult.Success) + .Callback(() => + { + // Identity's ChangeEmailAsync would mutate Email + EmailConfirmed atomically + // AND persist via UserManager.UpdateAsync. Simulate the persistence so that + // the handler's subsequent ReloadAsync sees the new state. + user.Email = "new@test.com"; + user.NormalizedEmail = "NEW@TEST.COM"; + user.EmailConfirmed = true; + _dbContext.SaveChangesAsync().GetAwaiter().GetResult(); + }); + + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest( + Email: "old@test.com", + NewEmail: "new@test.com", + Token: "valid-token"); + + // Act + var result = await _handler.HandleAsync(request); + + // Assert + Assert.False(result.IsError); + Assert.Null(user.PendingEmail); + Assert.Equal("new@test.com", user.Email); + Assert.True(user.EmailConfirmed); + } + + [Fact] + public async Task Handle_NoPendingEmail_ReturnsNoPendingChange() + { + // Arrange + var user = await SeedUserAsync("old@test.com", "user", pendingEmail: null); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); + + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest( + Email: "old@test.com", + NewEmail: "new@test.com", + Token: "any-token"); + + // Act + var result = await _handler.HandleAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.Equal("Email.NoPendingChange", result.FirstError.Code); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + _userManagerMock.Verify( + x => x.ChangeEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Handle_PendingEmailMismatch_ReturnsPendingMismatch() + { + // Arrange — user has staged a different email than the request + var user = await SeedUserAsync("old@test.com", "user", pendingEmail: "staged@test.com"); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); + + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest( + Email: "old@test.com", + NewEmail: "different@test.com", + Token: "any-token"); + + // Act + var result = await _handler.HandleAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.Equal("Email.PendingMismatch", result.FirstError.Code); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + _userManagerMock.Verify( + x => x.ChangeEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Handle_InvalidToken_ReturnsConfirmationFailed_AndPendingEmailIntact() + { + // Arrange + var user = await SeedUserAsync("old@test.com", "user", pendingEmail: "new@test.com"); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", "bad-token")) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = "InvalidToken", Description = "Invalid token." })); + + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest( + Email: "old@test.com", + NewEmail: "new@test.com", + Token: "bad-token"); + + // Act + var result = await _handler.HandleAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.Equal("Email.ConfirmationFailed", result.FirstError.Code); + // PendingEmail must remain set so the user can retry with a fresh staging. + Assert.Equal("new@test.com", user.PendingEmail); + Assert.Equal("old@test.com", user.Email); + } + + [Fact] + public async Task Handle_NonExistentUser_ReturnsConfirmationFailed() + { + // Arrange + _userManagerMock.Setup(x => x.FindByEmailAsync("missing@test.com")) + .ReturnsAsync((IdmtUser?)null); + + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest( + Email: "missing@test.com", + NewEmail: "new@test.com", + Token: "any-token"); + + // Act + var result = await _handler.HandleAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.Equal("Email.ConfirmationFailed", result.FirstError.Code); + } + + private async Task SeedUserAsync(string email, string username, string? pendingEmail) + { + var user = new IdmtUser + { + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = username, + NormalizedUserName = username.ToUpperInvariant(), + EmailConfirmed = true, + IsActive = true, + PendingEmail = pendingEmail, + }; + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + return user; + } + + public void Dispose() + { + _dbContext.Dispose(); + } +} diff --git a/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs index 8b1020b..e8dae79 100644 --- a/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs @@ -1,7 +1,6 @@ using ErrorOr; using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Models; -using Idmt.Plugin.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -10,35 +9,56 @@ namespace Idmt.UnitTests.Features.Auth; public class ConfirmEmailHandlerTests { - private readonly Mock _tenantOpsMock; - private readonly ConfirmEmail.ConfirmEmailHandler _handler; + private static Mock> CreateUserManagerMock() + { + return new Mock>( + new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); + } - public ConfirmEmailHandlerTests() + [Fact] + public async Task ReturnsSuccess_WhenTokenValid() { - _tenantOpsMock = new Mock(); + // Arrange + var user = new IdmtUser { UserName = "test", Email = "test@test.com" }; + var userManagerMock = CreateUserManagerMock(); + + userManagerMock + .Setup(u => u.FindByEmailAsync("test@test.com")) + .ReturnsAsync(user); + userManagerMock + .Setup(u => u.ConfirmEmailAsync(user, "valid-token")) + .ReturnsAsync(IdentityResult.Success); - _handler = new ConfirmEmail.ConfirmEmailHandler( - _tenantOpsMock.Object, + var handler = new ConfirmEmail.ConfirmEmailHandler( + userManagerMock.Object, NullLogger.Instance); + + var request = new ConfirmEmail.ConfirmEmailRequest("test@test.com", "valid-token"); + + // Act + var result = await handler.HandleAsync(request); + + // Assert + Assert.False(result.IsError); } [Fact] public async Task ReturnsConfirmationFailed_WhenUserNotFound() { // Arrange - var userManagerMock = new Mock>( - new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); - + var userManagerMock = CreateUserManagerMock(); userManagerMock .Setup(u => u.FindByEmailAsync(It.IsAny())) .ReturnsAsync((IdmtUser?)null); - SetupTenantOpsToInvokeLambda(userManagerMock); + var handler = new ConfirmEmail.ConfirmEmailHandler( + userManagerMock.Object, + NullLogger.Instance); - var request = new ConfirmEmail.ConfirmEmailRequest("test-tenant", "notfound@test.com", "token123"); + var request = new ConfirmEmail.ConfirmEmailRequest("notfound@test.com", "token123"); // Act - var result = await _handler.HandleAsync(request); + var result = await handler.HandleAsync(request); // Assert Assert.True(result.IsError); @@ -49,25 +69,24 @@ public async Task ReturnsConfirmationFailed_WhenUserNotFound() public async Task ReturnsConfirmationFailed_WhenTokenIsInvalid() { // Arrange - var user = new IdmtUser { UserName = "test", Email = "test@test.com", TenantId = "t1" }; - - var userManagerMock = new Mock>( - new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); + var user = new IdmtUser { UserName = "test", Email = "test@test.com" }; + var userManagerMock = CreateUserManagerMock(); userManagerMock .Setup(u => u.FindByEmailAsync(It.IsAny())) .ReturnsAsync(user); - userManagerMock .Setup(u => u.ConfirmEmailAsync(user, It.IsAny())) .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = "InvalidToken", Description = "Invalid token" })); - SetupTenantOpsToInvokeLambda(userManagerMock); + var handler = new ConfirmEmail.ConfirmEmailHandler( + userManagerMock.Object, + NullLogger.Instance); - var request = new ConfirmEmail.ConfirmEmailRequest("test-tenant", "test@test.com", "bad-token"); + var request = new ConfirmEmail.ConfirmEmailRequest("test@test.com", "bad-token"); // Act - var result = await _handler.HandleAsync(request); + var result = await handler.HandleAsync(request); // Assert Assert.True(result.IsError); @@ -78,44 +97,34 @@ public async Task ReturnsConfirmationFailed_WhenTokenIsInvalid() public async Task ReturnsUnexpected_OnException() { // Arrange - var userManagerMock = new Mock>( - new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); - + var userManagerMock = CreateUserManagerMock(); userManagerMock .Setup(u => u.FindByEmailAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("Database error")); - SetupTenantOpsToInvokeLambda(userManagerMock); + var handler = new ConfirmEmail.ConfirmEmailHandler( + userManagerMock.Object, + NullLogger.Instance); - var request = new ConfirmEmail.ConfirmEmailRequest("test-tenant", "test@test.com", "token123"); + var request = new ConfirmEmail.ConfirmEmailRequest("test@test.com", "token123"); // Act - var result = await _handler.HandleAsync(request); + var result = await handler.HandleAsync(request); // Assert Assert.True(result.IsError); Assert.Equal("General.Unexpected", result.FirstError.Code); } - #region Helpers - - private void SetupTenantOpsToInvokeLambda(Mock> userManagerMock) + [Fact] + public void Handler_Constructor_DoesNotDependOnTenantOperationService() { - _tenantOpsMock - .Setup(t => t.ExecuteInTenantScopeAsync( - It.IsAny(), - It.IsAny>>>(), - It.IsAny())) - .Returns>>, bool>( - async (_, operation, _) => - { - var serviceProviderMock = new Mock(); - serviceProviderMock - .Setup(sp => sp.GetService(typeof(UserManager))) - .Returns(userManagerMock.Object); - return await operation(serviceProviderMock.Object); - }); + // Regression: Step 5 removed body-supplied TenantIdentifier and the + // ExecuteInTenantScopeAsync wrap. Handler now resolves UserManager + // directly (canonical, global IdmtUser) without ITenantOperationService. + var ctors = typeof(ConfirmEmail.ConfirmEmailHandler).GetConstructors(); + Assert.Single(ctors); + var paramTypes = ctors[0].GetParameters().Select(p => p.ParameterType).ToArray(); + Assert.DoesNotContain(paramTypes, t => t.Name == "ITenantOperationService"); } - - #endregion } diff --git a/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs index 25e72fb..18e5875 100644 --- a/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs @@ -63,18 +63,27 @@ public async Task ReturnsEmptyArray_WhenNoUserMatches() [Fact] public async Task ReturnsTenant_WhenUserExistsInOneTenant() { - // Arrange + // Arrange — Phase 1: tenant membership is granted via TenantAccess; IdmtUser is global. var tenantId = Guid.CreateVersion7().ToString(); + var userId = Guid.NewGuid(); _dbContext.Set().Add( new IdmtTenantInfo(tenantId, "acme-corp", "Acme Corp")); _dbContext.Users.Add(new IdmtUser { + Id = userId, UserName = "alice", Email = "alice@test.com", NormalizedEmail = "ALICE@TEST.COM", IsActive = true, - TenantId = tenantId + }); + + _dbContext.TenantAccess.Add(new TenantAccess + { + UserId = userId, + TenantId = tenantId, + IsActive = true, + ExpiresAt = null }); await _dbContext.SaveChangesAsync(); @@ -103,7 +112,7 @@ public async Task ExcludesInactiveUsers() Email = "inactive@test.com", NormalizedEmail = "INACTIVE@TEST.COM", IsActive = false, - TenantId = tenantId + }); await _dbContext.SaveChangesAsync(); @@ -130,7 +139,7 @@ public async Task ExcludesInactiveTenants() Email = "bob@test.com", NormalizedEmail = "BOB@TEST.COM", IsActive = true, - TenantId = tenantId + }); await _dbContext.SaveChangesAsync(); @@ -146,14 +155,14 @@ public async Task ExcludesInactiveTenants() [Fact] public async Task IncludesTenantAccessGrants() { - // Arrange - var homeTenantId = Guid.CreateVersion7().ToString(); - var grantedTenantId = Guid.CreateVersion7().ToString(); + // Arrange — Phase 1: every tenant a user can reach is recorded as a TenantAccess row. + var firstTenantId = Guid.CreateVersion7().ToString(); + var secondTenantId = Guid.CreateVersion7().ToString(); var userId = Guid.NewGuid(); _dbContext.Set().AddRange( - new IdmtTenantInfo(homeTenantId, "home-tenant", "Home Tenant"), - new IdmtTenantInfo(grantedTenantId, "granted-tenant", "Granted Tenant")); + new IdmtTenantInfo(firstTenantId, "first-tenant", "First Tenant"), + new IdmtTenantInfo(secondTenantId, "second-tenant", "Second Tenant")); _dbContext.Users.Add(new IdmtUser { @@ -162,16 +171,12 @@ public async Task IncludesTenantAccessGrants() Email = "charlie@test.com", NormalizedEmail = "CHARLIE@TEST.COM", IsActive = true, - TenantId = homeTenantId }); - _dbContext.TenantAccess.Add(new TenantAccess - { - UserId = userId, - TenantId = grantedTenantId, - IsActive = true, - ExpiresAt = null - }); + _dbContext.TenantAccess.AddRange( + new TenantAccess { UserId = userId, TenantId = firstTenantId, IsActive = true }, + new TenantAccess { UserId = userId, TenantId = secondTenantId, IsActive = true }); + await _dbContext.SaveChangesAsync(); // Act @@ -181,20 +186,20 @@ public async Task IncludesTenantAccessGrants() // Assert Assert.False(result.IsError); Assert.Equal(2, result.Value.Tenants.Count); - Assert.Contains(result.Value.Tenants, t => t.Identifier == "home-tenant"); - Assert.Contains(result.Value.Tenants, t => t.Identifier == "granted-tenant"); + Assert.Contains(result.Value.Tenants, t => t.Identifier == "first-tenant"); + Assert.Contains(result.Value.Tenants, t => t.Identifier == "second-tenant"); } [Fact] public async Task ExcludesExpiredTenantAccessGrants() { - // Arrange - var homeTenantId = Guid.CreateVersion7().ToString(); + // Arrange — current grant is active, expired grant is excluded. + var activeTenantId = Guid.CreateVersion7().ToString(); var expiredTenantId = Guid.CreateVersion7().ToString(); var userId = Guid.NewGuid(); _dbContext.Set().AddRange( - new IdmtTenantInfo(homeTenantId, "home-tenant", "Home Tenant"), + new IdmtTenantInfo(activeTenantId, "active-tenant", "Active Tenant"), new IdmtTenantInfo(expiredTenantId, "expired-tenant", "Expired Tenant")); _dbContext.Users.Add(new IdmtUser @@ -204,16 +209,17 @@ public async Task ExcludesExpiredTenantAccessGrants() Email = "dave@test.com", NormalizedEmail = "DAVE@TEST.COM", IsActive = true, - TenantId = homeTenantId }); - _dbContext.TenantAccess.Add(new TenantAccess - { - UserId = userId, - TenantId = expiredTenantId, - IsActive = true, - ExpiresAt = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc) // yesterday - }); + _dbContext.TenantAccess.AddRange( + new TenantAccess { UserId = userId, TenantId = activeTenantId, IsActive = true, ExpiresAt = null }, + new TenantAccess + { + UserId = userId, + TenantId = expiredTenantId, + IsActive = true, + ExpiresAt = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc) // yesterday + }); await _dbContext.SaveChangesAsync(); // Act @@ -223,19 +229,19 @@ public async Task ExcludesExpiredTenantAccessGrants() // Assert Assert.False(result.IsError); Assert.Single(result.Value.Tenants); - Assert.Equal("home-tenant", result.Value.Tenants[0].Identifier); + Assert.Equal("active-tenant", result.Value.Tenants[0].Identifier); } [Fact] public async Task ExcludesInactiveTenantAccessGrants() { - // Arrange - var homeTenantId = Guid.CreateVersion7().ToString(); + // Arrange — current grant is active, revoked (IsActive=false) grant is excluded. + var activeTenantId = Guid.CreateVersion7().ToString(); var revokedTenantId = Guid.CreateVersion7().ToString(); var userId = Guid.NewGuid(); _dbContext.Set().AddRange( - new IdmtTenantInfo(homeTenantId, "home-tenant", "Home Tenant"), + new IdmtTenantInfo(activeTenantId, "active-tenant", "Active Tenant"), new IdmtTenantInfo(revokedTenantId, "revoked-tenant", "Revoked Tenant")); _dbContext.Users.Add(new IdmtUser @@ -245,16 +251,11 @@ public async Task ExcludesInactiveTenantAccessGrants() Email = "eve@test.com", NormalizedEmail = "EVE@TEST.COM", IsActive = true, - TenantId = homeTenantId }); - _dbContext.TenantAccess.Add(new TenantAccess - { - UserId = userId, - TenantId = revokedTenantId, - IsActive = false, - ExpiresAt = null - }); + _dbContext.TenantAccess.AddRange( + new TenantAccess { UserId = userId, TenantId = activeTenantId, IsActive = true, ExpiresAt = null }, + new TenantAccess { UserId = userId, TenantId = revokedTenantId, IsActive = false, ExpiresAt = null }); await _dbContext.SaveChangesAsync(); // Act @@ -264,7 +265,7 @@ public async Task ExcludesInactiveTenantAccessGrants() // Assert Assert.False(result.IsError); Assert.Single(result.Value.Tenants); - Assert.Equal("home-tenant", result.Value.Tenants[0].Identifier); + Assert.Equal("active-tenant", result.Value.Tenants[0].Identifier); } [Fact] diff --git a/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs index 38864c9..e189de3 100644 --- a/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs @@ -38,7 +38,7 @@ public async Task ReturnsSuccess_WhenUserIsInactive() UserName = "inactive", Email = "inactive@test.com", IsActive = false, - TenantId = "t1" + }; _userManagerMock @@ -69,7 +69,7 @@ public async Task GeneratesPasswordResetLink_WhenUserExists() UserName = "testuser", Email = "test@test.com", IsActive = true, - TenantId = "t1" + }; _userManagerMock diff --git a/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs index f4b9e8e..e0cc025 100644 --- a/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs @@ -3,6 +3,7 @@ using Idmt.Plugin.Configuration; using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Models; +using Idmt.Plugin.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -17,6 +18,7 @@ public class LoginHandlerTests private readonly Mock> _userManagerMock; private readonly Mock> _signInManagerMock; private readonly Mock _tenantAccessorMock; + private readonly Mock _tenantAccessServiceMock; private readonly Mock _timeProviderMock; private readonly Login.LoginHandler _handler; @@ -54,6 +56,11 @@ public LoginHandlerTests() Mock.Of>()); _tenantAccessorMock = new Mock(); + _tenantAccessServiceMock = new Mock(); + // Default: TenantAccess gate passes — individual tests override as needed. + _tenantAccessServiceMock + .Setup(x => x.CanAccessTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); _timeProviderMock = new Mock(); _timeProviderMock.Setup(x => x.GetUtcNow()).Returns(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); @@ -61,6 +68,7 @@ public LoginHandlerTests() _userManagerMock.Object, _signInManagerMock.Object, _tenantAccessorMock.Object, + _tenantAccessServiceMock.Object, Options.Create(new IdmtOptions()), _timeProviderMock.Object, NullLogger.Instance); @@ -94,7 +102,6 @@ private static IdmtUser CreateActiveUser() => Id = Guid.NewGuid(), Email = "test@example.com", UserName = "testuser", - TenantId = "tenant-id", IsActive = true }; @@ -273,4 +280,49 @@ public async Task ReturnsUnexpected_WhenExceptionIsThrown() Assert.True(result.IsError); Assert.Equal("General.Unexpected", result.FirstError.Code); } + + [Fact] + public async Task Handle_NoTenantAccess_ReturnsUnauthorized() + { + SetupActiveTenant(); + var user = CreateActiveUser(); + _userManagerMock.Setup(x => x.FindByEmailAsync("test@example.com")).ReturnsAsync(user); + _signInManagerMock.Setup(x => x.CheckPasswordSignInAsync(user, "Password123!", true)) + .ReturnsAsync(SignInResult.Success); + + // TenantAccess gate denies — even valid credentials must not yield a session. + _tenantAccessServiceMock + .Setup(x => x.CanAccessTenantAsync(user.Id, "tenant-id", It.IsAny())) + .ReturnsAsync(false); + + var result = await _handler.HandleAsync(CreateRequest()); + + Assert.True(result.IsError); + Assert.Equal("Auth.Unauthorized", result.FirstError.Code); + // Sign-in must not have been issued. + _signInManagerMock.Verify(x => x.CreateUserPrincipalAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_HasTenantAccess_Succeeds() + { + SetupActiveTenant(); + var user = CreateActiveUser(); + _userManagerMock.Setup(x => x.FindByEmailAsync("test@example.com")).ReturnsAsync(user); + _signInManagerMock.Setup(x => x.CheckPasswordSignInAsync(user, "Password123!", true)) + .ReturnsAsync(SignInResult.Success); + _signInManagerMock.Setup(x => x.CreateUserPrincipalAsync(user)) + .ReturnsAsync(new ClaimsPrincipal(new ClaimsIdentity())); + _userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + + _tenantAccessServiceMock + .Setup(x => x.CanAccessTenantAsync(user.Id, "tenant-id", It.IsAny())) + .ReturnsAsync(true); + + var result = await _handler.HandleAsync(CreateRequest()); + + Assert.False(result.IsError); + Assert.Equal(user.Id, result.Value.UserId); + _tenantAccessServiceMock.Verify(x => x.CanAccessTenantAsync(user.Id, "tenant-id", It.IsAny()), Times.Once); + } } diff --git a/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs index f64949e..10a4366 100644 --- a/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs @@ -126,7 +126,7 @@ public async Task ReturnsInvalidToken_WhenSecurityStampValidationFails() public async Task ReturnsUnauthorized_WhenUserIsInactive() { // Arrange - var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = false, TenantId = "t1" }; + var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = false }; var expiresUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); var ticket = CreateTicket(expiresUtc: expiresUtc); SetupBearerOptions(unprotectResult: ticket); @@ -153,7 +153,7 @@ public async Task ReturnsUnauthorized_WhenUserIsInactive() public async Task ReturnsUnauthorized_WhenTokenTenantClaimIsNull() { // Arrange - ticket has no tenant claim - var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true, TenantId = "t1" }; + var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true }; var expiresUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); var ticket = CreateTicket(expiresUtc: expiresUtc, tenantClaim: null); SetupBearerOptions(unprotectResult: ticket); @@ -182,7 +182,7 @@ public async Task ReturnsUnauthorized_WhenTokenTenantClaimIsNull() public async Task ReturnsUnauthorized_WhenTokenTenantDoesNotMatchCurrentTenant() { // Arrange - token tenant is different from current tenant - var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true, TenantId = "t1" }; + var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true }; var expiresUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); var ticket = CreateTicket(expiresUtc: expiresUtc, tenantClaim: "other-tenant"); SetupBearerOptions(unprotectResult: ticket); @@ -211,7 +211,7 @@ public async Task ReturnsUnauthorized_WhenTokenTenantDoesNotMatchCurrentTenant() public async Task ReturnsUnauthorized_WhenCurrentTenantIsNull() { // Arrange - current tenant context is null - var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true, TenantId = "t1" }; + var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true }; var expiresUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); var ticket = CreateTicket(expiresUtc: expiresUtc, tenantClaim: TestTenantIdentifier); SetupBearerOptions(unprotectResult: ticket); @@ -244,7 +244,7 @@ public async Task ReturnsTokenRevoked_WhenTokenIsRevoked() { // Arrange - set up a valid refresh ticket that passes all existing checks var tenantId = "tid-12345"; - var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true, TenantId = tenantId }; + var user = new IdmtUser { UserName = "test", Email = "test@test.com", IsActive = true }; var expiresUtc = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); var issuedUtc = new DateTimeOffset(2026, 5, 2, 0, 0, 0, TimeSpan.Zero); var ticket = CreateTicket(expiresUtc: expiresUtc, tenantClaim: TestTenantIdentifier, issuedUtc: issuedUtc); diff --git a/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs index e9199c0..4f40036 100644 --- a/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs @@ -39,7 +39,7 @@ public async Task ReturnsSuccess_WhenEmailAlreadyConfirmed() Email = "confirmed@test.com", EmailConfirmed = true, IsActive = true, - TenantId = "t1" + }; _userManagerMock diff --git a/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs index 84f3d3c..de0b95e 100644 --- a/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs @@ -1,7 +1,5 @@ -using ErrorOr; using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Models; -using Idmt.Plugin.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -10,20 +8,22 @@ namespace Idmt.UnitTests.Features.Auth; public class ResetPasswordHandlerTests { - private readonly Mock _tenantOpsMock; - private readonly ResetPassword.ResetPasswordHandler _handler; - - public ResetPasswordHandlerTests() + private static Mock> CreateUserManagerMock() { - _tenantOpsMock = new Mock(); + return new Mock>( + new Mock>().Object, + null!, null!, null!, null!, null!, null!, null!, null!); + } - _handler = new ResetPassword.ResetPasswordHandler( - _tenantOpsMock.Object, + private static ResetPassword.ResetPasswordHandler CreateHandler(Mock> userManagerMock) + { + return new ResetPassword.ResetPasswordHandler( + userManagerMock.Object, NullLogger.Instance); } [Fact] - public async Task ReturnsResetFailed_WhenUserIsInactive() + public async Task Handle_InactiveUser_ReturnsResetFailed() { // Arrange var user = new IdmtUser @@ -31,22 +31,38 @@ public async Task ReturnsResetFailed_WhenUserIsInactive() UserName = "inactive", Email = "inactive@test.com", IsActive = false, - TenantId = "t1" }; - var userManagerMock = new Mock>( - new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); - + var userManagerMock = CreateUserManagerMock(); userManagerMock .Setup(u => u.FindByEmailAsync("inactive@test.com")) .ReturnsAsync(user); - SetupTenantOpsToInvokeLambda(userManagerMock); + var handler = CreateHandler(userManagerMock); + var request = new ResetPassword.ResetPasswordRequest("inactive@test.com", "token", "NewPass123!"); - var request = new ResetPassword.ResetPasswordRequest("test-tenant", "inactive@test.com", "token", "NewPass123!"); + // Act + var result = await handler.HandleAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.Equal("Password.ResetFailed", result.FirstError.Code); + } + + [Fact] + public async Task Handle_NonExistentEmail_ReturnsResetFailed() + { + // Arrange + var userManagerMock = CreateUserManagerMock(); + userManagerMock + .Setup(u => u.FindByEmailAsync(It.IsAny())) + .ReturnsAsync((IdmtUser?)null); + + var handler = CreateHandler(userManagerMock); + var request = new ResetPassword.ResetPasswordRequest("nobody@test.com", "token", "NewPass123!"); // Act - var result = await _handler.HandleAsync(request); + var result = await handler.HandleAsync(request); // Assert Assert.True(result.IsError); @@ -54,7 +70,7 @@ public async Task ReturnsResetFailed_WhenUserIsInactive() } [Fact] - public async Task ReturnsResetFailed_WhenIdentityResetFails() + public async Task Handle_InvalidToken_ReturnsResetFailed() { // Arrange var user = new IdmtUser @@ -62,12 +78,9 @@ public async Task ReturnsResetFailed_WhenIdentityResetFails() UserName = "testuser", Email = "test@test.com", IsActive = true, - TenantId = "t1" }; - var userManagerMock = new Mock>( - new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); - + var userManagerMock = CreateUserManagerMock(); userManagerMock .Setup(u => u.FindByEmailAsync("test@test.com")) .ReturnsAsync(user); @@ -76,12 +89,11 @@ public async Task ReturnsResetFailed_WhenIdentityResetFails() .Setup(u => u.ResetPasswordAsync(user, "bad-token", "NewPass123!")) .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = "InvalidToken", Description = "Invalid token" })); - SetupTenantOpsToInvokeLambda(userManagerMock); - - var request = new ResetPassword.ResetPasswordRequest("test-tenant", "test@test.com", "bad-token", "NewPass123!"); + var handler = CreateHandler(userManagerMock); + var request = new ResetPassword.ResetPasswordRequest("test@test.com", "bad-token", "NewPass123!"); // Act - var result = await _handler.HandleAsync(request); + var result = await handler.HandleAsync(request); // Assert Assert.True(result.IsError); @@ -89,21 +101,19 @@ public async Task ReturnsResetFailed_WhenIdentityResetFails() } [Fact] - public async Task SetsEmailConfirmed_WhenUserEmailWasUnconfirmed() + public async Task Handle_ValidToken_ResetsPassword_NoEmailConfirmedFlip() { - // Arrange + // C7 regression: password reset MUST NOT mutate EmailConfirmed. + // Arrange — pre-seed user with EmailConfirmed = false. var user = new IdmtUser { UserName = "testuser", Email = "test@test.com", IsActive = true, EmailConfirmed = false, - TenantId = "t1" }; - var userManagerMock = new Mock>( - new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); - + var userManagerMock = CreateUserManagerMock(); userManagerMock .Setup(u => u.FindByEmailAsync("test@test.com")) .ReturnsAsync(user); @@ -112,42 +122,89 @@ public async Task SetsEmailConfirmed_WhenUserEmailWasUnconfirmed() .Setup(u => u.ResetPasswordAsync(user, "valid-token", "NewPass123!")) .ReturnsAsync(IdentityResult.Success); + var handler = CreateHandler(userManagerMock); + var request = new ResetPassword.ResetPasswordRequest("test@test.com", "valid-token", "NewPass123!"); + + // Act + var result = await handler.HandleAsync(request); + + // Assert + Assert.False(result.IsError); + Assert.False(user.EmailConfirmed); + } + + [Fact] + public async Task Handle_ValidToken_ResetsPassword_PreservesEmailConfirmedTrue() + { + // Regression: handler should not flip EmailConfirmed in either direction. + var user = new IdmtUser + { + UserName = "testuser", + Email = "test@test.com", + IsActive = true, + EmailConfirmed = true, + }; + + var userManagerMock = CreateUserManagerMock(); userManagerMock - .Setup(u => u.UpdateAsync(user)) - .ReturnsAsync(IdentityResult.Success); + .Setup(u => u.FindByEmailAsync("test@test.com")) + .ReturnsAsync(user); - SetupTenantOpsToInvokeLambda(userManagerMock); + userManagerMock + .Setup(u => u.ResetPasswordAsync(user, "valid-token", "NewPass123!")) + .ReturnsAsync(IdentityResult.Success); - var request = new ResetPassword.ResetPasswordRequest("test-tenant", "test@test.com", "valid-token", "NewPass123!"); + var handler = CreateHandler(userManagerMock); + var request = new ResetPassword.ResetPasswordRequest("test@test.com", "valid-token", "NewPass123!"); // Act - var result = await _handler.HandleAsync(request); + var result = await handler.HandleAsync(request); // Assert Assert.False(result.IsError); Assert.True(user.EmailConfirmed); - userManagerMock.Verify(u => u.UpdateAsync(user), Times.Once); } - #region Helpers - - private void SetupTenantOpsToInvokeLambda(Mock> userManagerMock) + [Fact] + public async Task Handle_NoLongerInvokesUpdateAsyncForEmailConfirmedFlip() { - _tenantOpsMock - .Setup(t => t.ExecuteInTenantScopeAsync( - It.IsAny(), - It.IsAny>>>(), - It.IsAny())) - .Returns>>, bool>( - async (_, operation, _) => - { - var serviceProviderMock = new Mock(); - serviceProviderMock - .Setup(sp => sp.GetService(typeof(UserManager))) - .Returns(userManagerMock.Object); - return await operation(serviceProviderMock.Object); - }); + // C7 regression: handler must not call UpdateAsync to flip EmailConfirmed. + var user = new IdmtUser + { + UserName = "testuser", + Email = "test@test.com", + IsActive = true, + EmailConfirmed = false, + }; + + var userManagerMock = CreateUserManagerMock(); + userManagerMock + .Setup(u => u.FindByEmailAsync("test@test.com")) + .ReturnsAsync(user); + + userManagerMock + .Setup(u => u.ResetPasswordAsync(user, "valid-token", "NewPass123!")) + .ReturnsAsync(IdentityResult.Success); + + var handler = CreateHandler(userManagerMock); + var request = new ResetPassword.ResetPasswordRequest("test@test.com", "valid-token", "NewPass123!"); + + // Act + var result = await handler.HandleAsync(request); + + // Assert + Assert.False(result.IsError); + userManagerMock.Verify(u => u.UpdateAsync(It.IsAny()), Times.Never); } - #endregion + [Fact] + public void Handler_Constructor_DoesNotDependOnTenantOperationService() + { + // Regression: ctor signature should accept UserManager + ILogger only. + var ctors = typeof(ResetPassword.ResetPasswordHandler).GetConstructors(); + Assert.Single(ctors); + var paramTypes = ctors[0].GetParameters().Select(p => p.ParameterType).ToArray(); + Assert.Contains(paramTypes, t => t == typeof(UserManager)); + Assert.DoesNotContain(paramTypes, t => t.Name == "ITenantOperationService"); + } } diff --git a/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs index 4bfd6b8..3bbbc29 100644 --- a/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs @@ -2,6 +2,7 @@ using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Models; +using Idmt.Plugin.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Http; @@ -17,6 +18,7 @@ public class TokenLoginHandlerTests private readonly Mock> _userManagerMock; private readonly Mock> _signInManagerMock; private readonly Mock _tenantAccessorMock; + private readonly Mock _tenantAccessServiceMock; private readonly Mock> _bearerOptionsMock; private readonly Mock _timeProviderMock; private readonly Login.TokenLoginHandler _handler; @@ -42,6 +44,11 @@ public TokenLoginHandlerTests() Mock.Of>()); _tenantAccessorMock = new Mock(); + _tenantAccessServiceMock = new Mock(); + // Default: TenantAccess gate passes — individual tests override as needed. + _tenantAccessServiceMock + .Setup(x => x.CanAccessTenantAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); _bearerOptionsMock = new Mock>(); _timeProviderMock = new Mock(); _timeProviderMock.Setup(x => x.GetUtcNow()).Returns(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); @@ -50,6 +57,7 @@ public TokenLoginHandlerTests() _userManagerMock.Object, _signInManagerMock.Object, _tenantAccessorMock.Object, + _tenantAccessServiceMock.Object, _bearerOptionsMock.Object, _timeProviderMock.Object, NullLogger.Instance); @@ -79,7 +87,6 @@ private static IdmtUser CreateActiveUser() => Id = Guid.NewGuid(), Email = "test@example.com", UserName = "testuser", - TenantId = "tenant-id", IsActive = true }; @@ -197,4 +204,51 @@ public async Task ReturnsUnexpected_WhenExceptionIsThrown() Assert.True(result.IsError); Assert.Equal("General.Unexpected", result.FirstError.Code); } + + [Fact] + public async Task Handle_NoTenantAccess_ReturnsUnauthorized() + { + SetupActiveTenant(); + SetupBearerTokenOptions(); + var user = CreateActiveUser(); + _userManagerMock.Setup(x => x.FindByEmailAsync("test@example.com")).ReturnsAsync(user); + _signInManagerMock.Setup(x => x.CheckPasswordSignInAsync(user, "Password123!", true)) + .ReturnsAsync(SignInResult.Success); + + // TenantAccess gate denies — tokens must not be issued. + _tenantAccessServiceMock + .Setup(x => x.CanAccessTenantAsync(user.Id, "tenant-id", It.IsAny())) + .ReturnsAsync(false); + + var result = await _handler.HandleAsync(CreateRequest()); + + Assert.True(result.IsError); + Assert.Equal("Auth.Unauthorized", result.FirstError.Code); + // Token issuance must not have been attempted. + _signInManagerMock.Verify(x => x.CreateUserPrincipalAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_HasTenantAccess_Succeeds() + { + SetupActiveTenant(); + SetupBearerTokenOptions(); + var user = CreateActiveUser(); + _userManagerMock.Setup(x => x.FindByEmailAsync("test@example.com")).ReturnsAsync(user); + _signInManagerMock.Setup(x => x.CheckPasswordSignInAsync(user, "Password123!", true)) + .ReturnsAsync(SignInResult.Success); + _signInManagerMock.Setup(x => x.CreateUserPrincipalAsync(user)) + .ReturnsAsync(new ClaimsPrincipal(new ClaimsIdentity())); + _userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + + _tenantAccessServiceMock + .Setup(x => x.CanAccessTenantAsync(user.Id, "tenant-id", It.IsAny())) + .ReturnsAsync(true); + + var result = await _handler.HandleAsync(CreateRequest()); + + Assert.False(result.IsError); + Assert.Equal("test-access-token", result.Value.AccessToken); + _tenantAccessServiceMock.Verify(x => x.CanAccessTenantAsync(user.Id, "tenant-id", It.IsAny()), Times.Once); + } } diff --git a/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs index 8f241ce..99a89e6 100644 --- a/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs @@ -11,7 +11,7 @@ namespace Idmt.UnitTests.Features.Manage; public class GetUserInfoHandlerTests { private readonly Mock> _userManagerMock; - private readonly Mock> _tenantStoreMock; + private readonly Mock> _tenantAccessorMock; private readonly GetUserInfo.GetUserInfoHandler _handler; public GetUserInfoHandlerTests() @@ -20,25 +20,36 @@ public GetUserInfoHandlerTests() _userManagerMock = new Mock>( userStoreMock.Object, null!, null!, null!, null!, null!, null!, null!, null!); - _tenantStoreMock = new Mock>(); + _tenantAccessorMock = new Mock>(); _handler = new GetUserInfo.GetUserInfoHandler( _userManagerMock.Object, - _tenantStoreMock.Object); + _tenantAccessorMock.Object); + } + + private void SetTenantContext(IdmtTenantInfo? tenant) + { + if (tenant is null) + { + _tenantAccessorMock.SetupGet(x => x.MultiTenantContext) + .Returns((IMultiTenantContext)null!); + } + else + { + _tenantAccessorMock.SetupGet(x => x.MultiTenantContext) + .Returns(new MultiTenantContext(tenant)); + } } [Fact] public async Task ReturnsClaimsNotFound_WhenEmailClaimMissing() { - // Arrange - principal with no email claim var principal = new ClaimsPrincipal(new ClaimsIdentity([ new Claim(ClaimTypes.Name, "testuser") ], "Bearer")); - // Act var result = await _handler.HandleAsync(principal); - // Assert Assert.True(result.IsError); Assert.Equal("User.ClaimsNotFound", result.FirstError.Code); Assert.Equal(ErrorType.Validation, result.FirstError.Type); @@ -47,15 +58,12 @@ public async Task ReturnsClaimsNotFound_WhenEmailClaimMissing() [Fact] public async Task ReturnsNotFound_WhenUserDoesNotExistInDb() { - // Arrange var principal = CreatePrincipalWithEmail("nonexistent@test.com"); _userManagerMock.Setup(x => x.FindByEmailAsync("nonexistent@test.com")) .ReturnsAsync((IdmtUser?)null); - // Act var result = await _handler.HandleAsync(principal); - // Assert Assert.True(result.IsError); Assert.Equal("User.NotFound", result.FirstError.Code); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); @@ -64,21 +72,17 @@ public async Task ReturnsNotFound_WhenUserDoesNotExistInDb() [Fact] public async Task ReturnsNotFound_WhenUserIsInactive() { - // Arrange var principal = CreatePrincipalWithEmail("inactive@test.com"); var user = new IdmtUser { UserName = "inactive", Email = "inactive@test.com", - TenantId = "tenant-1", IsActive = false }; _userManagerMock.Setup(x => x.FindByEmailAsync("inactive@test.com")).ReturnsAsync(user); - // Act var result = await _handler.HandleAsync(principal); - // Assert Assert.True(result.IsError); Assert.Equal("User.NotFound", result.FirstError.Code); } @@ -86,47 +90,39 @@ public async Task ReturnsNotFound_WhenUserIsInactive() [Fact] public async Task ReturnsNoRolesAssigned_WhenUserHasNoRoles() { - // Arrange var principal = CreatePrincipalWithEmail("noroles@test.com"); var user = new IdmtUser { UserName = "noroles", Email = "noroles@test.com", - TenantId = "tenant-1", IsActive = true }; _userManagerMock.Setup(x => x.FindByEmailAsync("noroles@test.com")).ReturnsAsync(user); _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync([]); - // Act var result = await _handler.HandleAsync(principal); - // Assert Assert.True(result.IsError); Assert.Equal("User.NoRolesAssigned", result.FirstError.Code); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } [Fact] - public async Task ReturnsTenantNotFound_WhenTenantDoesNotExist() + public async Task ReturnsTenantNotFound_WhenAmbientTenantContextMissing() { - // Arrange var principal = CreatePrincipalWithEmail("user@test.com"); var user = new IdmtUser { UserName = "testuser", Email = "user@test.com", - TenantId = "missing-tenant", IsActive = true }; _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(["TenantAdmin"]); - _tenantStoreMock.Setup(x => x.GetAsync("missing-tenant")).ReturnsAsync((IdmtTenantInfo?)null); + SetTenantContext(null); - // Act var result = await _handler.HandleAsync(principal); - // Assert Assert.True(result.IsError); Assert.Equal("Tenant.NotFound", result.FirstError.Code); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); @@ -135,25 +131,21 @@ public async Task ReturnsTenantNotFound_WhenTenantDoesNotExist() [Fact] public async Task ReturnsAllRoles_WhenUserHasSingleRole() { - // Arrange var principal = CreatePrincipalWithEmail("user@test.com"); var user = new IdmtUser { UserName = "testuser", Email = "user@test.com", - TenantId = "tenant-1", IsActive = true }; - var tenant = new IdmtTenantInfo("tenant-1", "Tenant One"); + var tenant = new IdmtTenantInfo("tenant-1", "tenant-1", "Tenant One"); _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(["Member"]); - _tenantStoreMock.Setup(x => x.GetAsync("tenant-1")).ReturnsAsync(tenant); + SetTenantContext(tenant); - // Act var result = await _handler.HandleAsync(principal); - // Assert Assert.False(result.IsError); Assert.Single(result.Value.Roles); Assert.Equal("Member", result.Value.Roles[0]); @@ -162,31 +154,79 @@ public async Task ReturnsAllRoles_WhenUserHasSingleRole() [Fact] public async Task ReturnsAllRoles_SortedAlphabetically_WhenUserHasMultipleRoles() { - // Arrange var principal = CreatePrincipalWithEmail("multi@test.com"); var user = new IdmtUser { UserName = "multiuser", Email = "multi@test.com", - TenantId = "tenant-1", IsActive = true }; - var tenant = new IdmtTenantInfo("tenant-1", "Tenant One"); + var tenant = new IdmtTenantInfo("tenant-1", "tenant-1", "Tenant One"); _userManagerMock.Setup(x => x.FindByEmailAsync("multi@test.com")).ReturnsAsync(user); - // Roles are intentionally supplied in non-alphabetical order to verify sorting. _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(["TenantAdmin", "Member", "Auditor"]); - _tenantStoreMock.Setup(x => x.GetAsync("tenant-1")).ReturnsAsync(tenant); + SetTenantContext(tenant); - // Act var result = await _handler.HandleAsync(principal); - // Assert Assert.False(result.IsError); Assert.Equal(3, result.Value.Roles.Count); Assert.Equal(new[] { "Auditor", "Member", "TenantAdmin" }, result.Value.Roles); } + [Fact] + public async Task SysAdmin_WithNoPerTenantRoles_ReturnsSysAdminRole() + { + // Phase 1 (canonical identity): a SysAdmin user need not carry a per-tenant IdentityRole + // row — sys authority is sourced from IdmtUser.SysRole. The handler must surface SysAdmin + // in the Roles list so the user does not appear "role-less". + var principal = CreatePrincipalWithEmail("sysadmin@test.com"); + var user = new IdmtUser + { + UserName = "sysadmin", + Email = "sysadmin@test.com", + IsActive = true, + SysRole = SysRoleKind.SysAdmin, + }; + var tenant = new IdmtTenantInfo("tenant-1", "tenant-1", "Tenant One"); + + _userManagerMock.Setup(x => x.FindByEmailAsync("sysadmin@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync([]); + SetTenantContext(tenant); + + var result = await _handler.HandleAsync(principal); + + Assert.False(result.IsError); + Assert.Single(result.Value.Roles); + Assert.Equal("SysAdmin", result.Value.Roles[0]); + } + + [Fact] + public async Task SysAdmin_WithPerTenantRole_ReturnsBothRoles() + { + // Union path: per-tenant IdentityRole rows + SysRole both surface in the response. + var principal = CreatePrincipalWithEmail("dual@test.com"); + var user = new IdmtUser + { + UserName = "dual", + Email = "dual@test.com", + IsActive = true, + SysRole = SysRoleKind.SysAdmin, + }; + var tenant = new IdmtTenantInfo("tenant-1", "tenant-1", "Tenant One"); + + _userManagerMock.Setup(x => x.FindByEmailAsync("dual@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(["TenantAdmin"]); + SetTenantContext(tenant); + + var result = await _handler.HandleAsync(principal); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Roles.Count); + Assert.Contains("SysAdmin", result.Value.Roles); + Assert.Contains("TenantAdmin", result.Value.Roles); + } + private static ClaimsPrincipal CreatePrincipalWithEmail(string email) { return new ClaimsPrincipal(new ClaimsIdentity([ diff --git a/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs index 871ec82..fcd8bed 100644 --- a/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs @@ -58,7 +58,7 @@ public async Task ReturnsForbidden_WhenCallerCannotManageTargetUser() Id = userId, UserName = "target@test.com", Email = "target@test.com", - TenantId = "tenant-1" + }; SetupUsersQueryable([user]); _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(["SysAdmin"]); @@ -83,7 +83,7 @@ public async Task ReturnsDeletionFailed_WhenDeleteFails() Id = userId, UserName = "target@test.com", Email = "target@test.com", - TenantId = "tenant-1" + }; SetupUsersQueryable([user]); _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(["TenantAdmin"]); diff --git a/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs index 7925166..7fa915e 100644 --- a/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs @@ -54,7 +54,7 @@ public async Task ReturnsForbidden_WhenCannotManageUser() Id = userId, UserName = "target@test.com", Email = "target@test.com", - TenantId = "tenant-1" + }; SetupUsersQueryable([user]); _userManagerMock.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(["SysAdmin"]); @@ -81,7 +81,7 @@ public async Task ReturnsUpdateFailed_WhenIdentityUpdateFails() Id = userId, UserName = "target@test.com", Email = "target@test.com", - TenantId = "tenant-1", + IsActive = true }; SetupUsersQueryable([user]); diff --git a/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs index 060d2ee..c67d204 100644 --- a/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs @@ -91,7 +91,6 @@ public async Task ReturnsInactive_WhenUserIsInactive() { UserName = "inactive", Email = "inactive@test.com", - TenantId = "tenant-1", IsActive = false }; _userManagerMock.Setup(x => x.FindByEmailAsync("inactive@test.com")).ReturnsAsync(user); @@ -108,20 +107,13 @@ public async Task ReturnsInactive_WhenUserIsInactive() } [Fact] - public async Task SkipsUpdate_WhenNoFieldsChanged() + public async Task SkipsEmailFlow_WhenNoFieldsChanged() { // Arrange var principal = CreatePrincipalWithEmail("user@test.com"); - var user = new IdmtUser - { - UserName = "currentname", - Email = "user@test.com", - TenantId = "tenant-1", - IsActive = true - }; + var user = await SeedUserAsync(email: "user@test.com", username: "currentname"); _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); - // Request with no changes (all null) var request = new UpdateUserInfo.UpdateUserInfoRequest(); // Act @@ -129,36 +121,22 @@ public async Task SkipsUpdate_WhenNoFieldsChanged() // Assert Assert.False(result.IsError); - _userManagerMock.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + Assert.False(result.Value.EmailChangePending); + _userManagerMock.Verify(x => x.GenerateChangeEmailTokenAsync(It.IsAny(), It.IsAny()), Times.Never); } - /// - /// Verifies the critical fix: after a successful email change, a confirmation email is sent - /// to the new address so the user has a recovery path and is not permanently locked out. - /// [Fact] - public async Task SendsConfirmationEmail_WhenEmailChanged() + public async Task DoesNotMutateEmail_WhenEmailChangeRequested_StagesPendingEmail() { - // Arrange + // Arrange — invariant: user.Email column is NOT mutated; only PendingEmail is set. var principal = CreatePrincipalWithEmail("old@test.com"); - var user = new IdmtUser - { - UserName = "testuser", - Email = "old@test.com", - TenantId = "tenant-1", - IsActive = true, - EmailConfirmed = true - }; + var user = await SeedUserAsync(email: "old@test.com", username: "testuser", emailConfirmed: true); _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); _userManagerMock.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com")) .ReturnsAsync("change-token"); - _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", "change-token")) - .ReturnsAsync(IdentityResult.Success); - _userManagerMock.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)) - .ReturnsAsync("confirm-token"); _linkGeneratorMock - .Setup(x => x.GenerateConfirmEmailLink("new@test.com", "confirm-token")) - .Returns("https://example.com/confirm?token=confirm-token"); + .Setup(x => x.GenerateConfirmEmailChangeLink("old@test.com", "new@test.com", "change-token")) + .Returns("https://example.com/confirm-email-change?token=change-token"); var request = new UpdateUserInfo.UpdateUserInfoRequest(NewEmail: "new@test.com"); @@ -167,49 +145,59 @@ public async Task SendsConfirmationEmail_WhenEmailChanged() // Assert Assert.False(result.IsError); + Assert.True(result.Value.EmailChangePending); + Assert.Equal("old@test.com", user.Email); + Assert.Equal("new@test.com", user.PendingEmail); + + // ChangeEmailAsync MUST NOT be invoked at the staging step. + _userManagerMock.Verify( + x => x.ChangeEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SendsConfirmationLinkToNewEmail_WhenEmailChangeRequested() + { + // Arrange + var principal = CreatePrincipalWithEmail("old@test.com"); + var user = await SeedUserAsync(email: "old@test.com", username: "testuser", emailConfirmed: true); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com")) + .ReturnsAsync("change-token"); + _linkGeneratorMock + .Setup(x => x.GenerateConfirmEmailChangeLink("old@test.com", "new@test.com", "change-token")) + .Returns("https://example.com/confirm-email-change?token=change-token"); + + var request = new UpdateUserInfo.UpdateUserInfoRequest(NewEmail: "new@test.com"); - // The link generator must be called with the NEW email address and the fresh confirm token + // Act + var result = await _handler.HandleAsync(request, principal); + + // Assert + Assert.False(result.IsError); _linkGeneratorMock.Verify( - x => x.GenerateConfirmEmailLink("new@test.com", "confirm-token"), + x => x.GenerateConfirmEmailChangeLink("old@test.com", "new@test.com", "change-token"), Times.Once); - - // The email sender must be called with the NEW email address and the generated link _emailSenderMock.Verify( x => x.SendConfirmationLinkAsync( user, "new@test.com", - "https://example.com/confirm?token=confirm-token"), + "https://example.com/confirm-email-change?token=change-token"), Times.Once); } - /// - /// Verifies the critical fix: when only the email changes, UpdateAsync must NOT be called. - /// ChangeEmailAsync already persists the change; a second UpdateAsync would write with a - /// stale concurrency stamp and could silently corrupt the user record. - /// [Fact] - public async Task DoesNotCallUpdateAsync_WhenOnlyEmailChanged() + public async Task ReturnsResultEmailChangePendingTrue_WhenEmailChangeRequested() { // Arrange var principal = CreatePrincipalWithEmail("old@test.com"); - var user = new IdmtUser - { - UserName = "testuser", - Email = "old@test.com", - TenantId = "tenant-1", - IsActive = true, - EmailConfirmed = true - }; + var user = await SeedUserAsync(email: "old@test.com", username: "testuser", emailConfirmed: true); _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); _userManagerMock.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com")) .ReturnsAsync("change-token"); - _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", "change-token")) - .ReturnsAsync(IdentityResult.Success); - _userManagerMock.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)) - .ReturnsAsync("confirm-token"); _linkGeneratorMock - .Setup(x => x.GenerateConfirmEmailLink("new@test.com", "confirm-token")) - .Returns("https://example.com/confirm?token=confirm-token"); + .Setup(x => x.GenerateConfirmEmailChangeLink("old@test.com", "new@test.com", "change-token")) + .Returns("https://example.com/confirm-email-change"); var request = new UpdateUserInfo.UpdateUserInfoRequest(NewEmail: "new@test.com"); @@ -218,57 +206,203 @@ public async Task DoesNotCallUpdateAsync_WhenOnlyEmailChanged() // Assert Assert.False(result.IsError); + Assert.True(result.Value.EmailChangePending); + } + + [Fact] + public async Task ReturnsResultEmailChangePendingFalse_WhenNoEmailChangeRequested() + { + // Arrange + var principal = CreatePrincipalWithEmail("user@test.com"); + var user = await SeedUserAsync(email: "user@test.com", username: "currentname"); + _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.SetUserNameAsync(user, "newname")) + .ReturnsAsync(IdentityResult.Success) + .Callback(() => user.UserName = "newname"); + + var request = new UpdateUserInfo.UpdateUserInfoRequest(NewUsername: "newname"); - // UpdateAsync must NOT be called — ChangeEmailAsync already saved the email change - _userManagerMock.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + // Act + var result = await _handler.HandleAsync(request, principal); + + // Assert + Assert.False(result.IsError); + Assert.False(result.Value.EmailChangePending); } /// - /// Verifies that when both username and email change in the same request, UpdateAsync is - /// still called exactly once for the username change (ChangeEmailAsync handles the email). + /// F25 (CD-1 regression): when both NewPassword and NewEmail are requested, the + /// change-email token must validate at confirm time. The handler MUST flush + reload + /// AFTER ChangePasswordAsync rotates SecurityStamp and BEFORE + /// GenerateChangeEmailTokenAsync, so the token binds to the post-rotation stamp. /// [Fact] - public async Task CallsUpdateAsync_WhenUsernameAndEmailBothChanged() + public async Task PasswordAndEmailChange_TokenStillValidAtConfirmTime() { // Arrange var principal = CreatePrincipalWithEmail("old@test.com"); - var user = new IdmtUser - { - UserName = "oldname", - Email = "old@test.com", - TenantId = "tenant-1", - IsActive = true, - EmailConfirmed = true - }; + var user = await SeedUserAsync(email: "old@test.com", username: "testuser", emailConfirmed: true); _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); - _userManagerMock.Setup(x => x.SetUserNameAsync(user, "newname")) - .ReturnsAsync(IdentityResult.Success); + + // Simulate ChangePasswordAsync rotating the SecurityStamp. + var stampAtPasswordChange = string.Empty; + _userManagerMock.Setup(x => x.ChangePasswordAsync(user, "OldP@ss1!", "NewP@ss1!")) + .ReturnsAsync(IdentityResult.Success) + .Callback(() => + { + user.SecurityStamp = Guid.NewGuid().ToString(); + stampAtPasswordChange = user.SecurityStamp; + }); + + // Token generation must observe the rotated stamp. + var stampAtTokenGen = string.Empty; _userManagerMock.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com")) - .ReturnsAsync("change-token"); - _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", "change-token")) + .ReturnsAsync(() => + { + stampAtTokenGen = user.SecurityStamp ?? string.Empty; + return $"change-token-{user.SecurityStamp}"; + }); + + _linkGeneratorMock.Setup(x => x.GenerateConfirmEmailChangeLink( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("https://example.com/confirm-email-change"); + + var request = new UpdateUserInfo.UpdateUserInfoRequest( + OldPassword: "OldP@ss1!", + NewPassword: "NewP@ss1!", + NewEmail: "new@test.com"); + + // Act + var result = await _handler.HandleAsync(request, principal); + + // Assert + Assert.False(result.IsError); + Assert.True(result.Value.EmailChangePending); + + // Token must be generated AFTER password change rotates stamp. + Assert.False(string.IsNullOrEmpty(stampAtPasswordChange)); + Assert.Equal(stampAtPasswordChange, stampAtTokenGen); + + // Now simulate the user clicking the link — confirm time. Identity's + // ChangeEmailAsync validates the token against the user's CURRENT stamp. + // Stamp has not rotated again, so the token validates. + _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", $"change-token-{stampAtPasswordChange}")) .ReturnsAsync(IdentityResult.Success); - _userManagerMock.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)) - .ReturnsAsync("confirm-token"); - _linkGeneratorMock - .Setup(x => x.GenerateConfirmEmailLink("new@test.com", "confirm-token")) - .Returns("https://example.com/confirm?token=confirm-token"); - _userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); - var request = new UpdateUserInfo.UpdateUserInfoRequest(NewUsername: "newname", NewEmail: "new@test.com"); + var confirmHandler = new Idmt.Plugin.Features.Auth.ConfirmEmailChange.ConfirmEmailChangeHandler( + _userManagerMock.Object, + _dbContext, + NullLogger.Instance); + + var confirmRequest = new Idmt.Plugin.Features.Auth.ConfirmEmailChange.ConfirmEmailChangeRequest( + Email: "old@test.com", + NewEmail: "new@test.com", + Token: $"change-token-{stampAtPasswordChange}"); + + var confirmResult = await confirmHandler.HandleAsync(confirmRequest); + Assert.False(confirmResult.IsError); + } + + /// + /// F44 (CD-1 widened): same coupling for username + email change. + /// + [Fact] + public async Task UsernameAndEmailChange_TokenStillValidAtConfirmTime() + { + // Arrange + var principal = CreatePrincipalWithEmail("old@test.com"); + var user = await SeedUserAsync(email: "old@test.com", username: "oldname", emailConfirmed: true); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); + + // Simulate SetUserNameAsync rotating the stamp. + var stampAfterUsername = string.Empty; + _userManagerMock.Setup(x => x.SetUserNameAsync(user, "newname")) + .ReturnsAsync(IdentityResult.Success) + .Callback(() => + { + user.UserName = "newname"; + user.SecurityStamp = Guid.NewGuid().ToString(); + stampAfterUsername = user.SecurityStamp; + }); + + var stampAtTokenGen = string.Empty; + _userManagerMock.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com")) + .ReturnsAsync(() => + { + stampAtTokenGen = user.SecurityStamp ?? string.Empty; + return $"change-token-{user.SecurityStamp}"; + }); + + _linkGeneratorMock.Setup(x => x.GenerateConfirmEmailChangeLink( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("https://example.com/confirm-email-change"); + + var request = new UpdateUserInfo.UpdateUserInfoRequest( + NewUsername: "newname", + NewEmail: "new@test.com"); // Act var result = await _handler.HandleAsync(request, principal); // Assert Assert.False(result.IsError); + Assert.True(result.Value.EmailChangePending); + Assert.False(string.IsNullOrEmpty(stampAfterUsername)); + Assert.Equal(stampAfterUsername, stampAtTokenGen); - // UpdateAsync must be called exactly once for the username change - _userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once); + _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", $"change-token-{stampAfterUsername}")) + .ReturnsAsync(IdentityResult.Success); - // Confirmation email must still be sent for the email change - _emailSenderMock.Verify( - x => x.SendConfirmationLinkAsync(user, "new@test.com", It.IsAny()), - Times.Once); + var confirmHandler = new Idmt.Plugin.Features.Auth.ConfirmEmailChange.ConfirmEmailChangeHandler( + _userManagerMock.Object, + _dbContext, + NullLogger.Instance); + + var confirmRequest = new Idmt.Plugin.Features.Auth.ConfirmEmailChange.ConfirmEmailChangeRequest( + Email: "old@test.com", + NewEmail: "new@test.com", + Token: $"change-token-{stampAfterUsername}"); + + var confirmResult = await confirmHandler.HandleAsync(confirmRequest); + Assert.False(confirmResult.IsError); + } + + /// + /// Verifies the ordering invariant: GenerateChangeEmailTokenAsync runs strictly + /// AFTER ChangePasswordAsync. Use a sequence to assert the exact call order. + /// + [Fact] + public async Task FlushReloadOrderingPreserved_GenerateTokenAfterPasswordChange() + { + // Arrange + var principal = CreatePrincipalWithEmail("old@test.com"); + var user = await SeedUserAsync(email: "old@test.com", username: "testuser", emailConfirmed: true); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); + + var sequence = new MockSequence(); + _userManagerMock.InSequence(sequence) + .Setup(x => x.ChangePasswordAsync(user, "OldP@ss1!", "NewP@ss1!")) + .ReturnsAsync(IdentityResult.Success); + _userManagerMock.InSequence(sequence) + .Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com")) + .ReturnsAsync("change-token"); + + _linkGeneratorMock.Setup(x => x.GenerateConfirmEmailChangeLink( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("https://example.com/confirm-email-change"); + + var request = new UpdateUserInfo.UpdateUserInfoRequest( + OldPassword: "OldP@ss1!", + NewPassword: "NewP@ss1!", + NewEmail: "new@test.com"); + + // Act + var result = await _handler.HandleAsync(request, principal); + + // Assert: sequence will throw if ordering is violated. + Assert.False(result.IsError); + _userManagerMock.Verify(x => x.ChangePasswordAsync(user, "OldP@ss1!", "NewP@ss1!"), Times.Once); + _userManagerMock.Verify(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com"), Times.Once); } [Fact] @@ -276,17 +410,9 @@ public async Task DoesNotChangeEmail_WhenNewEmailSameAsCurrent() { // Arrange var principal = CreatePrincipalWithEmail("same@test.com"); - var user = new IdmtUser - { - UserName = "testuser", - Email = "same@test.com", - TenantId = "tenant-1", - IsActive = true, - EmailConfirmed = true - }; + var user = await SeedUserAsync(email: "same@test.com", username: "testuser", emailConfirmed: true); _userManagerMock.Setup(x => x.FindByEmailAsync("same@test.com")).ReturnsAsync(user); - // Request with same email as current var request = new UpdateUserInfo.UpdateUserInfoRequest(NewEmail: "same@test.com"); // Act @@ -294,9 +420,13 @@ public async Task DoesNotChangeEmail_WhenNewEmailSameAsCurrent() // Assert Assert.False(result.IsError); - _userManagerMock.Verify(x => x.ChangeEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _userManagerMock.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); - _emailSenderMock.Verify(x => x.SendConfirmationLinkAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.False(result.Value.EmailChangePending); + _userManagerMock.Verify( + x => x.GenerateChangeEmailTokenAsync(It.IsAny(), It.IsAny()), + Times.Never); + _emailSenderMock.Verify( + x => x.SendConfirmationLinkAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); } [Fact] @@ -304,16 +434,9 @@ public async Task DoesNotChangeUsername_WhenNewUsernameSameAsCurrent() { // Arrange var principal = CreatePrincipalWithEmail("user@test.com"); - var user = new IdmtUser - { - UserName = "currentname", - Email = "user@test.com", - TenantId = "tenant-1", - IsActive = true - }; + var user = await SeedUserAsync(email: "user@test.com", username: "currentname"); _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); - // Request with same username as current var request = new UpdateUserInfo.UpdateUserInfoRequest(NewUsername: "currentname"); // Act @@ -321,32 +444,54 @@ public async Task DoesNotChangeUsername_WhenNewUsernameSameAsCurrent() // Assert Assert.False(result.IsError); + Assert.False(result.Value.EmailChangePending); _userManagerMock.Verify(x => x.SetUserNameAsync(It.IsAny(), It.IsAny()), Times.Never); - _userManagerMock.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); } - /// - /// Verifies that a failed ChangeEmailAsync rolls back the transaction and returns an error - /// without attempting to send a confirmation email. - /// [Fact] - public async Task ReturnsUpdateFailed_WhenChangeEmailFails() + public async Task ReturnsPasswordResetFailed_WhenChangePasswordFails() { // Arrange + var principal = CreatePrincipalWithEmail("user@test.com"); + var user = await SeedUserAsync(email: "user@test.com", username: "testuser"); + _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.ChangePasswordAsync(user, "OldP@ss1!", "NewP@ss1!")) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = "Bad", Description = "no" })); + + var request = new UpdateUserInfo.UpdateUserInfoRequest( + OldPassword: "OldP@ss1!", + NewPassword: "NewP@ss1!"); + + // Act + var result = await _handler.HandleAsync(request, principal); + + // Assert + Assert.True(result.IsError); + Assert.Equal("Password.ResetFailed", result.FirstError.Code); + + // Email flow must NOT be triggered when password change failed. + _emailSenderMock.Verify( + x => x.SendConfirmationLinkAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Handle_EmailOnlyChangeRequested_DoesNotRevokeTokens() + { + // Arrange — email-only change must NOT revoke bearer tokens at staging time. + // Revocation happens naturally at confirm time via SecurityStamp rotation. + var userId = Guid.NewGuid(); var principal = CreatePrincipalWithEmail("old@test.com"); - var user = new IdmtUser - { - UserName = "testuser", - Email = "old@test.com", - TenantId = "tenant-1", - IsActive = true, - EmailConfirmed = true - }; + var user = await SeedUserAsync(email: "old@test.com", username: "testuser", emailConfirmed: true); _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); _userManagerMock.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@test.com")) .ReturnsAsync("change-token"); - _userManagerMock.Setup(x => x.ChangeEmailAsync(user, "new@test.com", "change-token")) - .ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = "Error", Description = "Change failed" })); + _linkGeneratorMock.Setup(x => x.GenerateConfirmEmailChangeLink( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("https://example.com/confirm-email-change"); + + _handlerCurrentUserServiceMock.SetupGet(x => x.UserId).Returns(userId); + _handlerCurrentUserServiceMock.SetupGet(x => x.TenantId).Returns("tenant-1"); var request = new UpdateUserInfo.UpdateUserInfoRequest(NewEmail: "new@test.com"); @@ -354,15 +499,89 @@ public async Task ReturnsUpdateFailed_WhenChangeEmailFails() var result = await _handler.HandleAsync(request, principal); // Assert - Assert.True(result.IsError); - Assert.Equal("User.UpdateFailed", result.FirstError.Code); - - // No confirmation email should be sent when the change itself failed - _emailSenderMock.Verify( - x => x.SendConfirmationLinkAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Assert.False(result.IsError); + Assert.True(result.Value.EmailChangePending); + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task Handle_PasswordChangeRequested_RevokesTokens() + { + // Arrange — credentials path must still revoke (regression). + var userId = Guid.NewGuid(); + var principal = CreatePrincipalWithEmail("user@test.com"); + var user = await SeedUserAsync(email: "user@test.com", username: "testuser"); + _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.ChangePasswordAsync(user, "OldP@ss1!", "NewP@ss1!")) + .ReturnsAsync(IdentityResult.Success); + + _handlerCurrentUserServiceMock.SetupGet(x => x.UserId).Returns(userId); + _handlerCurrentUserServiceMock.SetupGet(x => x.TenantId).Returns("tenant-1"); + + var request = new UpdateUserInfo.UpdateUserInfoRequest( + OldPassword: "OldP@ss1!", + NewPassword: "NewP@ss1!"); + + // Act + var result = await _handler.HandleAsync(request, principal); + + // Assert + Assert.False(result.IsError); + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(userId, "tenant-1", It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Handle_UsernameChangeRequested_RevokesTokens() + { + // Arrange — username change rotates SecurityStamp, must revoke bearer tokens. + var userId = Guid.NewGuid(); + var principal = CreatePrincipalWithEmail("user@test.com"); + var user = await SeedUserAsync(email: "user@test.com", username: "oldname"); + _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); + _userManagerMock.Setup(x => x.SetUserNameAsync(user, "newname")) + .ReturnsAsync(IdentityResult.Success) + .Callback(() => user.UserName = "newname"); + + _handlerCurrentUserServiceMock.SetupGet(x => x.UserId).Returns(userId); + _handlerCurrentUserServiceMock.SetupGet(x => x.TenantId).Returns("tenant-1"); + + var request = new UpdateUserInfo.UpdateUserInfoRequest(NewUsername: "newname"); + + // Act + var result = await _handler.HandleAsync(request, principal); + + // Assert + Assert.False(result.IsError); + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(userId, "tenant-1", It.IsAny()), + Times.Once); + } + + /// + /// Helper: persists a user to the in-memory IdmtDbContext so that EF tracks the entity. + /// Required because the new staging path calls dbContext.Entry(user).ReloadAsync, which + /// only works on tracked entities. + /// + private async Task SeedUserAsync(string email, string username, bool emailConfirmed = false) + { + var user = new IdmtUser + { + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = username, + NormalizedUserName = username.ToUpperInvariant(), + EmailConfirmed = emailConfirmed, + IsActive = true, + }; + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + return user; + } + private static ClaimsPrincipal CreatePrincipalWithEmail(string email) { return new ClaimsPrincipal(new ClaimsIdentity([ diff --git a/tests/Idmt.UnitTests/Migration/CanonicalIdentityDataMigratorTests.cs b/tests/Idmt.UnitTests/Migration/CanonicalIdentityDataMigratorTests.cs new file mode 100644 index 0000000..7241429 --- /dev/null +++ b/tests/Idmt.UnitTests/Migration/CanonicalIdentityDataMigratorTests.cs @@ -0,0 +1,382 @@ +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Migration; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Idmt.UnitTests.Migration; + +/// +/// Unit tests for . +/// +/// +/// Coverage scope (per Step 11 plan §A): happy path + null/edge cases. The migrator is a +/// documented harness, not a production-grade tool; full integration coverage of +/// IdentityUserRole / IdentityUserToken rewrites belongs to consumer-side validation. +/// SQLite (in-memory) is used because the migrator relies on ExecuteUpdateAsync which +/// is not supported by the EF InMemory provider. +/// +public sealed class CanonicalIdentityDataMigratorTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _serviceProvider; + private readonly IdmtDbContext _db; + + public CanonicalIdentityDataMigratorTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var services = new ServiceCollection(); + + var tenantAccessor = new Mock(); + var dummyTenant = new IdmtTenantInfo("system-test", "system-test", "System Test Tenant"); + tenantAccessor.SetupGet(x => x.MultiTenantContext) + .Returns(new MultiTenantContext(dummyTenant)); + + services.AddSingleton(tenantAccessor.Object); + services.AddScoped(); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddLogging(); + + services.AddDbContext(options => + options.UseSqlite(_connection)); + + services.AddSingleton(); + + _serviceProvider = services.BuildServiceProvider(); + _db = _serviceProvider.GetRequiredService(); + _db.Database.EnsureCreated(); + + // Drop the Phase-1 NormalizedEmail unique index so the test fixture can simulate + // pre-migration shadow-row data (multiple IdmtUser rows sharing a NormalizedEmail). + // Real pre-migration databases have no such global uniqueness constraint. + DropNormalizedEmailIndex(_connection); + } + + private static void DropNormalizedEmailIndex(SqliteConnection conn) + { + using var lookup = conn.CreateCommand(); + lookup.CommandText = "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='AspNetUsers' AND sql LIKE '%NormalizedEmail%'"; + var indexNames = new List(); + using (var reader = lookup.ExecuteReader()) + { + while (reader.Read()) + { + indexNames.Add(reader.GetString(0)); + } + } + foreach (var name in indexNames) + { + using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP INDEX IF EXISTS \"{name}\""; + drop.ExecuteNonQuery(); + } + } + + public void Dispose() + { + _serviceProvider.Dispose(); + _connection.Dispose(); + } + + private (IServiceProvider provider, IdmtDbContext db) BuildHarness() => (_serviceProvider, _db); + + [Fact] + public async Task DryRun_NoUsers_ReportsZeroGroups() + { + var (provider, _) = BuildHarness(); + + var migrator = provider.GetRequiredService(); + var report = await migrator.DryRunAsync(); + + Assert.Equal(0, report.TotalUsers); + Assert.Empty(report.DuplicateGroups); + Assert.False(string.IsNullOrEmpty(report.Fingerprint)); + } + + [Fact] + public async Task DryRun_NoDuplicates_ReportsZeroGroups() + { + var (provider, db) = BuildHarness(); + + db.Users.Add(NewUser("alice@example.com")); + db.Users.Add(NewUser("bob@example.com")); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var report = await migrator.DryRunAsync(); + + Assert.Equal(2, report.TotalUsers); + Assert.Empty(report.DuplicateGroups); + } + + [Fact] + public async Task DryRun_DuplicateEmails_GroupsAndPicksOldestAsCanonical() + { + var (provider, db) = BuildHarness(); + + // GUIDv7 ids are time-ordered; create older first so its id sorts smallest. + var older = NewUser("dup@example.com"); + await Task.Delay(5); + var newer = NewUser("dup@example.com"); + db.Users.AddRange(older, newer); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var report = await migrator.DryRunAsync(); + + var group = Assert.Single(report.DuplicateGroups); + Assert.Equal("DUP@EXAMPLE.COM", group.NormalizedEmail); + Assert.Equal(older.Id, group.CanonicalUserId); + Assert.Single(group.DuplicateUserIds); + Assert.Equal(newer.Id, group.DuplicateUserIds[0]); + } + + [Fact] + public async Task DryRun_FoldsHighestSysRoleAcrossDuplicates() + { + var (provider, db) = BuildHarness(); + + var canonical = NewUser("dup@example.com", SysRoleKind.None); + await Task.Delay(5); + var dup = NewUser("dup@example.com", SysRoleKind.SysAdmin); + db.Users.AddRange(canonical, dup); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var report = await migrator.DryRunAsync(); + + var group = Assert.Single(report.DuplicateGroups); + Assert.Equal(SysRoleKind.SysAdmin, group.FoldedSysRole); + } + + [Fact] + public async Task DryRun_FingerprintIsStableForSameInput() + { + var (provider, db) = BuildHarness(); + + db.Users.Add(NewUser("a@example.com")); + db.Users.Add(NewUser("b@example.com")); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var first = await migrator.DryRunAsync(); + var second = await migrator.DryRunAsync(); + + Assert.Equal(first.Fingerprint, second.Fingerprint); + } + + [Fact] + public async Task Apply_RefusesWithoutAck() + { + var (provider, _) = BuildHarness(); + + var migrator = provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => + migrator.ApplyAsync(string.Empty, [])); + } + + [Fact] + public async Task Apply_RefusesWithStaleFingerprint() + { + var (provider, _) = BuildHarness(); + + var migrator = provider.GetRequiredService(); + + var ex = await Assert.ThrowsAsync(() => + migrator.ApplyAsync("0000000000000000000000000000000000000000000000000000000000000000", [])); + + Assert.Contains("fingerprint mismatch", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Apply_NoDuplicates_StillRotatesAllSecurityStamps() + { + var (provider, db) = BuildHarness(); + + var u1 = NewUser("alice@example.com"); + var u2 = NewUser("bob@example.com"); + var stamp1 = u1.SecurityStamp; + var stamp2 = u2.SecurityStamp; + db.Users.AddRange(u1, u2); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var dry = await migrator.DryRunAsync(); + var apply = await migrator.ApplyAsync(dry.Fingerprint, []); + + Assert.Equal(0, apply.GroupsProcessed); + Assert.Equal(2, apply.StampsRotated); + + // Reload from underlying store; in-memory provider tracks stamp mutation directly. + var reloaded1 = await db.Users.AsNoTracking().FirstAsync(u => u.Id == u1.Id); + var reloaded2 = await db.Users.AsNoTracking().FirstAsync(u => u.Id == u2.Id); + Assert.NotEqual(stamp1, reloaded1.SecurityStamp); + Assert.NotEqual(stamp2, reloaded2.SecurityStamp); + } + + [Fact] + public async Task Apply_RewritesTenantAccessAndDropsDuplicates() + { + var (provider, db) = BuildHarness(); + + var canonical = NewUser("dup@example.com"); + await Task.Delay(5); + var dup = NewUser("dup@example.com"); + db.Users.AddRange(canonical, dup); + + // TenantAccess pointing at the duplicate id — should be rewritten to canonical id. + var ta = new TenantAccess + { + UserId = dup.Id, + TenantId = "tenant-x", + IsActive = true, + }; + db.TenantAccess.Add(ta); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var dry = await migrator.DryRunAsync(); + var apply = await migrator.ApplyAsync(dry.Fingerprint, []); + + Assert.Equal(1, apply.GroupsProcessed); + Assert.Equal(1, apply.Rewrites.TenantAccess); + Assert.Equal(1, apply.Rewrites.DuplicatesDeleted); + + var reloadedTa = await db.TenantAccess.AsNoTracking().FirstAsync(t => t.Id == ta.Id); + Assert.Equal(canonical.Id, reloadedTa.UserId); + + var remainingUsers = await db.Users.AsNoTracking().ToListAsync(); + Assert.Single(remainingUsers); + Assert.Equal(canonical.Id, remainingUsers[0].Id); + } + + [Fact] + public async Task Apply_FoldsSysRoleHighestWins() + { + var (provider, db) = BuildHarness(); + + var canonical = NewUser("dup@example.com", SysRoleKind.None); + await Task.Delay(5); + var dup = NewUser("dup@example.com", SysRoleKind.SysAdmin); + db.Users.AddRange(canonical, dup); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var dry = await migrator.DryRunAsync(); + await migrator.ApplyAsync(dry.Fingerprint, []); + + var reloaded = await db.Users.AsNoTracking().FirstAsync(u => u.Id == canonical.Id); + Assert.Equal(SysRoleKind.SysAdmin, reloaded.SysRole); + } + + [Fact] + public async Task Apply_RewritesAuditLogsForDuplicateUserId() + { + var (provider, db) = BuildHarness(); + + var canonical = NewUser("dup@example.com"); + await Task.Delay(5); + var dup = NewUser("dup@example.com"); + db.Users.AddRange(canonical, dup); + await db.SaveChangesAsync(); + + // Attribute an audit row to the duplicate user. + db.AuditLogs.Add(new IdmtAuditLog + { + UserId = dup.Id, + Action = "Test", + Resource = nameof(IdmtUser), + Timestamp = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var dry = await migrator.DryRunAsync(); + var apply = await migrator.ApplyAsync(dry.Fingerprint, []); + + Assert.True(apply.Rewrites.AuditLogs >= 1); + var audits = await db.AuditLogs.AsNoTracking().Where(a => a.Resource == "IdmtUser" && a.Action == "Test").ToListAsync(); + Assert.All(audits, a => Assert.Equal(canonical.Id, a.UserId)); + } + + [Fact] + public async Task Apply_SaveChangesAsyncFails_RollsBackBulkOperations() + { + // Verifies the transaction wrap in ApplyAsync. Without a transaction, the bulk + // ExecuteDeleteAsync against AspNetUsers (Step 6 in ApplyGroupAsync) auto-commits + // immediately and would leave the database with the duplicate row dropped even + // if the trailing SaveChangesAsync throws. With BeginTransactionAsync wrapping + // both modes, all writes roll back together. + var (provider, db) = BuildHarness(); + + var canonical = NewUser("dup@example.com"); + await Task.Delay(5); + var dup = NewUser("dup@example.com"); + db.Users.AddRange(canonical, dup); + + // Force SaveChangesAsync to fail by pre-creating a unique-index collision: both + // canonical and duplicate already have a TenantAccess for the same tenant. After + // the migrator rewrites dupTa.UserId → canonical.Id, the (UserId, TenantId) + // unique index is violated at SaveChangesAsync time. By that point the bulk + // ExecuteDeleteAsync against AspNetUsers has already executed; the transaction + // must roll it back. + db.TenantAccess.Add(new TenantAccess + { + UserId = canonical.Id, + TenantId = "tenant-x", + IsActive = true, + }); + db.TenantAccess.Add(new TenantAccess + { + UserId = dup.Id, + TenantId = "tenant-x", + IsActive = true, + }); + await db.SaveChangesAsync(); + + var migrator = provider.GetRequiredService(); + var dry = await migrator.DryRunAsync(); + + await Assert.ThrowsAsync(() => + migrator.ApplyAsync(dry.Fingerprint, [])); + + // Both users must still be present — the bulk delete in ApplyGroupAsync Step 6 + // must have been rolled back along with the failed SaveChangesAsync. + var remainingUsers = await db.Users.AsNoTracking().ToListAsync(); + Assert.Equal(2, remainingUsers.Count); + Assert.Contains(remainingUsers, u => u.Id == canonical.Id); + Assert.Contains(remainingUsers, u => u.Id == dup.Id); + } + + [Fact] + public async Task Apply_NullArgumentForCrossTenantList_Throws() + { + var (provider, _) = BuildHarness(); + var migrator = provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => + migrator.ApplyAsync("ack", null!)); + } + + private static IdmtUser NewUser(string email, SysRoleKind sysRole = SysRoleKind.None) => + new() + { + Id = Guid.CreateVersion7(), + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + EmailConfirmed = true, + IsActive = true, + SysRole = sysRole, + }; +} diff --git a/tests/Idmt.UnitTests/Models/IdmtUserTests.cs b/tests/Idmt.UnitTests/Models/IdmtUserTests.cs new file mode 100644 index 0000000..f0b8913 --- /dev/null +++ b/tests/Idmt.UnitTests/Models/IdmtUserTests.cs @@ -0,0 +1,20 @@ +using Idmt.Plugin.Models; + +namespace Idmt.UnitTests.Models; + +public class IdmtUserTests +{ + [Fact] + public void New_DefaultsSysRoleToNone() + { + var user = new IdmtUser(); + Assert.Equal(SysRoleKind.None, user.SysRole); + } + + [Fact] + public void New_DefaultsPendingEmailToNull() + { + var user = new IdmtUser(); + Assert.Null(user.PendingEmail); + } +} diff --git a/tests/Idmt.UnitTests/Models/SysRoleKindTests.cs b/tests/Idmt.UnitTests/Models/SysRoleKindTests.cs new file mode 100644 index 0000000..bae6012 --- /dev/null +++ b/tests/Idmt.UnitTests/Models/SysRoleKindTests.cs @@ -0,0 +1,37 @@ +using Idmt.Plugin.Models; + +namespace Idmt.UnitTests.Models; + +public class SysRoleKindTests +{ + [Fact] + public void Enum_SysAdmin_StringValue_EqualsSysAdmin() + { + Assert.Equal("SysAdmin", SysRoleKind.SysAdmin.ToString()); + } + + [Fact] + public void Enum_SysSupport_StringValue_EqualsSysSupport() + { + Assert.Equal("SysSupport", SysRoleKind.SysSupport.ToString()); + } + + [Fact] + public void Enum_None_StringValue_EqualsNone() + { + Assert.Equal("None", SysRoleKind.None.ToString()); + } + + [Fact] + public void Enum_None_IsZero() + { + Assert.Equal(0, (int)SysRoleKind.None); + } + + [Fact] + public void Enum_StringValue_MatchesIdmtDefaultRoleTypes() + { + Assert.Equal(IdmtDefaultRoleTypes.SysAdmin, SysRoleKind.SysAdmin.ToString()); + Assert.Equal(IdmtDefaultRoleTypes.SysSupport, SysRoleKind.SysSupport.ToString()); + } +} diff --git a/tests/Idmt.UnitTests/Persistence/IdmtDbContextTests.cs b/tests/Idmt.UnitTests/Persistence/IdmtDbContextTests.cs new file mode 100644 index 0000000..e6de70a --- /dev/null +++ b/tests/Idmt.UnitTests/Persistence/IdmtDbContextTests.cs @@ -0,0 +1,97 @@ +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Idmt.UnitTests.Persistence; + +/// +/// Phase 1 (canonical identity) model assertions for : +/// IdmtUser is a global entity (no Finbuckle multi-tenant filter) and carries a global +/// unique index on NormalizedEmail. +/// +public class IdmtDbContextTests +{ + private static IdmtDbContext CreateContext() + { + var tenantAccessor = new Mock(); + var dummyTenant = new IdmtTenantInfo("system-test-tenant", "system-test", "System Test Tenant"); + tenantAccessor.SetupGet(x => x.MultiTenantContext) + .Returns(new MultiTenantContext(dummyTenant)); + + var currentUser = new Mock(); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + return new IdmtDbContext( + tenantAccessor.Object, + options, + currentUser.Object, + TimeProvider.System, + NullLogger.Instance); + } + + [Fact] + public void OnModelCreating_IdmtUser_NotMultiTenant() + { + // Arrange + using var context = CreateContext(); + var entityType = context.Model.FindEntityType(typeof(IdmtUser)); + Assert.NotNull(entityType); + + // Act + Assert: Finbuckle stamps every multi-tenant entity with a "multiTenant" annotation + // (`Finbuckle.MultiTenant.Annotations.MultiTenant`). Phase 1 made IdmtUser a global entity, + // so no such annotation should be present. + var annotations = entityType!.GetAnnotations() + .Where(a => a.Name.Contains("multiTenant", StringComparison.OrdinalIgnoreCase) + || a.Name.Contains("MultiTenant", StringComparison.Ordinal)) + .ToList(); + Assert.Empty(annotations); + + // Belt-and-suspenders: ensure the entity does not declare a tenant-scoped query filter on + // a TenantId shadow property. + var tenantIdProperty = entityType.FindProperty("TenantId"); + Assert.Null(tenantIdProperty); + } + + [Fact] + public void OnModelCreating_IdmtUser_HasNormalizedEmailUniqueIndex() + { + // Arrange + using var context = CreateContext(); + var entityType = context.Model.FindEntityType(typeof(IdmtUser)); + Assert.NotNull(entityType); + + // Act + var indexes = entityType!.GetIndexes().ToList(); + var normalizedEmailIndex = indexes.FirstOrDefault(ix => + ix.Properties.Count == 1 && + string.Equals(ix.Properties[0].Name, nameof(IdmtUser.NormalizedEmail), StringComparison.Ordinal)); + + // Assert + Assert.NotNull(normalizedEmailIndex); + Assert.True(normalizedEmailIndex!.IsUnique, + "Phase 1 requires NormalizedEmail to be globally unique."); + } + + [Fact] + public void OnModelCreating_IdmtUser_DoesNotHaveLegacyEmailUserNameTenantIdIndex() + { + // Arrange + using var context = CreateContext(); + var entityType = context.Model.FindEntityType(typeof(IdmtUser)); + Assert.NotNull(entityType); + + // Act + Assert: legacy unique index on (Email, UserName, TenantId) must be gone. + var legacy = entityType!.GetIndexes() + .FirstOrDefault(ix => ix.Properties.Any(p => + string.Equals(p.Name, "TenantId", StringComparison.Ordinal))); + Assert.Null(legacy); + } +} diff --git a/tests/Idmt.UnitTests/Services/CoreServicesTests.cs b/tests/Idmt.UnitTests/Services/CoreServicesTests.cs index a20d6ea..c21d023 100644 --- a/tests/Idmt.UnitTests/Services/CoreServicesTests.cs +++ b/tests/Idmt.UnitTests/Services/CoreServicesTests.cs @@ -295,7 +295,8 @@ public void GenerateConfirmEmailLink_ClientForm_IncludesAllQueryParameters() var uri = new Uri(result); var query = QueryHelpers.ParseQuery(uri.Query); - Assert.Equal(_tenantInfo.Identifier, query["tenantIdentifier"].ToString()); + // Locked decision (Phase 1, Step 8): no tenantIdentifier in URL query params. + Assert.False(query.ContainsKey("tenantIdentifier")); Assert.Equal(email, query["email"].ToString()); // Token is Base64URL-encoded Assert.NotEmpty(query["token"].ToString()); @@ -313,7 +314,8 @@ public void GeneratePasswordResetLink_IncludesAllQueryParameters() var uri = new Uri(result); var query = QueryHelpers.ParseQuery(uri.Query); - Assert.Equal(_tenantInfo.Identifier, query["tenantIdentifier"].ToString()); + // Locked decision (Phase 1, Step 8): no tenantIdentifier in URL query params. + Assert.False(query.ContainsKey("tenantIdentifier")); Assert.Equal(email, query["email"].ToString()); // Token is Base64URL-encoded Assert.NotEmpty(query["token"].ToString()); diff --git a/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs b/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs index 3102d42..8ef66a3 100644 --- a/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs +++ b/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs @@ -85,7 +85,9 @@ public void GenerateConfirmEmailLink_ServerConfirm_UsesLinkGenerator() Assert.NotNull(capturedRouteValues); Assert.Equal(email, capturedRouteValues!["email"]?.ToString()); Assert.Equal(expectedEncodedToken, capturedRouteValues["token"]?.ToString()); - Assert.Equal(_tenantInfo.Identifier, capturedRouteValues["tenantIdentifier"]?.ToString()); + // Locked decision (Phase 1, Step 8): tenantIdentifier intentionally NOT included + // as a route value (would become a ?tenantIdentifier= query param on this route). + Assert.False(capturedRouteValues.ContainsKey("tenantIdentifier")); } [Fact] @@ -104,7 +106,8 @@ public void GenerateConfirmEmailLink_ClientForm_ReturnsClientUri() Assert.Equal(expectedBase, uri.GetLeftPart(UriPartial.Path)); var query = QueryHelpers.ParseQuery(uri.Query); - Assert.Equal(_tenantInfo.Identifier, query["tenantIdentifier"].ToString()); + // Locked decision (Phase 1, Step 8): no tenantIdentifier in URL query params. + Assert.False(query.ContainsKey("tenantIdentifier")); Assert.Equal(email, query["email"].ToString()); // Token should be Base64URL-encoded @@ -113,6 +116,26 @@ public void GenerateConfirmEmailLink_ClientForm_ReturnsClientUri() Assert.Equal(token, decodedToken); } + [Fact] + public void GenerateConfirmEmailLink_DoesNotEmbedTenantIdentifier() + { + // Locked decision (Phase 1, Step 8): tenantIdentifier stripped from confirm-email URL. + const string email = "user@example.com"; + const string token = "confirm-token"; + _options.Application.EmailConfirmationMode = EmailConfirmationMode.ClientForm; + _options.Application.ClientUrl = "https://client.example"; + _options.Application.ConfirmEmailFormPath = "/confirm-email"; + + var result = _service.GenerateConfirmEmailLink(email, token); + var uri = new Uri(result); + var query = QueryHelpers.ParseQuery(uri.Query); + + Assert.True(query.ContainsKey("email")); + Assert.True(query.ContainsKey("token")); + Assert.False(query.ContainsKey("tenantIdentifier")); + Assert.DoesNotContain("tenantIdentifier", result, StringComparison.Ordinal); + } + [Fact] public void GeneratePasswordResetLink_ReturnsClientUri() { @@ -128,7 +151,8 @@ public void GeneratePasswordResetLink_ReturnsClientUri() Assert.Equal(expectedBase, uri.GetLeftPart(UriPartial.Path)); var query = QueryHelpers.ParseQuery(uri.Query); - Assert.Equal(_tenantInfo.Identifier, query["tenantIdentifier"].ToString()); + // Locked decision (Phase 1, Step 8): no tenantIdentifier in URL query params. + Assert.False(query.ContainsKey("tenantIdentifier")); Assert.Equal(email, query["email"].ToString()); // Token should be Base64URL-encoded @@ -137,6 +161,25 @@ public void GeneratePasswordResetLink_ReturnsClientUri() Assert.Equal(token, decodedToken); } + [Fact] + public void GeneratePasswordResetLink_DoesNotEmbedTenantIdentifier() + { + // Locked decision (Phase 1, Step 8): tenantIdentifier stripped from reset-password URL. + const string email = "user@example.com"; + const string token = "reset-token"; + _options.Application.ClientUrl = "https://client.example"; + _options.Application.ResetPasswordFormPath = "/reset-password"; + + var result = _service.GeneratePasswordResetLink(email, token); + var uri = new Uri(result); + var query = QueryHelpers.ParseQuery(uri.Query); + + Assert.True(query.ContainsKey("email")); + Assert.True(query.ContainsKey("token")); + Assert.False(query.ContainsKey("tenantIdentifier")); + Assert.DoesNotContain("tenantIdentifier", result, StringComparison.Ordinal); + } + [Fact] public void GenerateConfirmEmailLink_ThrowsWhenHttpContextMissing() { diff --git a/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs b/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs index de2b931..f0997dc 100644 --- a/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs +++ b/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs @@ -10,15 +10,22 @@ namespace Idmt.UnitTests.Services; /// -/// Unit tests for IdmtUserClaimsPrincipalFactory. -/// Tests that custom claims (is_active and tenant) are correctly added to the user's claims identity. +/// Unit tests for . +/// +/// Phase 1 (canonical identity) behaviour pinned here: +/// - Tenant claim is sourced from the ambient ; +/// no longer carries a TenantId column. +/// - Principal generation throws when the ambient +/// tenant context is null (CD-4 fail-closed). +/// - is emitted as Claim(ClaimTypes.Role, "SysAdmin"|"SysSupport") +/// when the user has a non-None SysRole. /// public class IdmtUserClaimsPrincipalFactoryTests { private readonly Mock> _userManagerMock; private readonly Mock> _roleManagerMock; private readonly Mock> _identityOptionsMock; - private readonly Mock> _tenantStoreMock; + private readonly Mock _multiTenantContextAccessorMock; private readonly Mock> _idmtOptionsMock; private readonly IdmtUserClaimsPrincipalFactory _factory; @@ -55,7 +62,6 @@ public IdmtUserClaimsPrincipalFactoryTests() { ClaimsIdentity = new ClaimsIdentityOptions { - // Configure claim types to avoid null value issues EmailClaimType = ClaimTypes.Email, RoleClaimType = ClaimTypes.Role, SecurityStampClaimType = "AspNet.Identity.SecurityStamp", @@ -65,7 +71,7 @@ public IdmtUserClaimsPrincipalFactoryTests() }; _identityOptionsMock.Setup(x => x.Value).Returns(identityOptions); - _tenantStoreMock = new Mock>(); + _multiTenantContextAccessorMock = new Mock(); _idmtOptionsMock = new Mock>(); _idmtOptionsMock.Setup(x => x.Value).Returns(IdmtOptions.Default); @@ -74,123 +80,98 @@ public IdmtUserClaimsPrincipalFactoryTests() _userManagerMock.Object, _roleManagerMock.Object, _identityOptionsMock.Object, - _tenantStoreMock.Object, + _multiTenantContextAccessorMock.Object, _idmtOptionsMock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } + private void SetAmbientTenant(string tenantId, string tenantIdentifier, string name = "Test Tenant") + { + var tenant = new IdmtTenantInfo(tenantId, tenantIdentifier, name); + var context = new MultiTenantContext(tenant); + _multiTenantContextAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(context); + } + + private void SetAmbientTenantNull() + { + _multiTenantContextAccessorMock.SetupGet(x => x.MultiTenantContext) + .Returns((IMultiTenantContext)null!); + } + private async Task CallGenerateClaimsAsync(IdmtUser user) { var method = typeof(IdmtUserClaimsPrincipalFactory) - .GetMethod("GenerateClaimsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (method == null) + .GetMethod("GenerateClaimsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?? throw new InvalidOperationException("GenerateClaimsAsync method not found."); + try + { + return (ClaimsIdentity)await (dynamic)method.Invoke(_factory, new object[] { user })!; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null) { - throw new InvalidOperationException("GenerateClaimsAsync method not found."); + // Re-throw inner so xUnit can match against the original exception type. + throw tie.InnerException; } - return (ClaimsIdentity)await (dynamic)method.Invoke(_factory, new object[] { user })!; } - [Fact] - public async Task CreateAsync_AddsIsActiveClaim_WithCorrectValue() + private static IdmtUser BuildUser(SysRoleKind sysRole = SysRoleKind.None) => new() { - const string tenantId = "tenant-id-123"; - const string tenantIdentifier = "tenant-123"; - var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); - - var user = new IdmtUser - { - Id = Guid.NewGuid(), - UserName = "testuser", - NormalizedUserName = "TESTUSER", - Email = "test@example.com", - NormalizedEmail = "TEST@EXAMPLE.COM", - EmailConfirmed = true, - PhoneNumber = "1234567890", - PhoneNumberConfirmed = true, - TwoFactorEnabled = false, - LockoutEnabled = false, - AccessFailedCount = 0, - TenantId = tenantId, - IsActive = true, - SecurityStamp = Guid.NewGuid().ToString(), - ConcurrencyStamp = Guid.NewGuid().ToString() - }; + Id = Guid.NewGuid(), + UserName = "testuser", + NormalizedUserName = "TESTUSER", + Email = "test@example.com", + NormalizedEmail = "TEST@EXAMPLE.COM", + EmailConfirmed = true, + IsActive = true, + SysRole = sysRole, + SecurityStamp = Guid.NewGuid().ToString(), + ConcurrencyStamp = Guid.NewGuid().ToString() + }; - _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) - .ReturnsAsync(tenantInfo); + [Fact] + public async Task GenerateClaims_WithAmbientTenant_EmitsTenantClaimFromAmbient() + { + SetAmbientTenant("tenant-id-123", "tenant-123"); + var user = BuildUser(); var identity = await CallGenerateClaimsAsync(user); - var isActiveClaim = identity.FindFirst("is_active"); - Assert.NotNull(isActiveClaim); - Assert.Equal("True", isActiveClaim.Value); + var tenantClaim = identity.FindFirst(IdmtMultiTenantStrategy.DefaultClaim); + Assert.NotNull(tenantClaim); + Assert.Equal("tenant-123", tenantClaim.Value); } [Fact] - public async Task CreateAsync_AddsIsActiveClaim_WhenUserIsInactive() + public async Task GenerateClaims_EmitsIsActiveClaim_WithCorrectValue() { - const string tenantId = "tenant-id-123"; - const string tenantIdentifier = "tenant-123"; - var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); - - var user = new IdmtUser - { - Id = Guid.NewGuid(), - UserName = "testuser", - Email = "test@example.com", - TenantId = tenantId, - IsActive = false, - SecurityStamp = Guid.NewGuid().ToString(), - ConcurrencyStamp = Guid.NewGuid().ToString() - }; - - _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) - .ReturnsAsync(tenantInfo); + SetAmbientTenant("tenant-id-123", "tenant-123"); + var user = BuildUser(); var identity = await CallGenerateClaimsAsync(user); var isActiveClaim = identity.FindFirst("is_active"); Assert.NotNull(isActiveClaim); - Assert.Equal("False", isActiveClaim.Value); + Assert.Equal("True", isActiveClaim.Value); } [Fact] - public async Task CreateAsync_AddsTenantClaim_WithDefaultClaimType() + public async Task GenerateClaims_EmitsIsActiveClaim_WhenUserIsInactive() { - const string tenantId = "tenant-id-456"; - const string tenantIdentifier = "tenant-456"; - var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); - - var user = new IdmtUser - { - Id = Guid.NewGuid(), - UserName = "testuser", - Email = "test@example.com", - TenantId = tenantId, - IsActive = true, - SecurityStamp = Guid.NewGuid().ToString(), - ConcurrencyStamp = Guid.NewGuid().ToString() - }; - - _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) - .ReturnsAsync(tenantInfo); + SetAmbientTenant("tenant-id-123", "tenant-123"); + var user = BuildUser(); + user.IsActive = false; var identity = await CallGenerateClaimsAsync(user); - var tenantClaim = identity.FindFirst(IdmtMultiTenantStrategy.DefaultClaim); - Assert.NotNull(tenantClaim); - // The factory adds tenantInfo.Identifier, not tenantId - Assert.Equal(tenantIdentifier, tenantClaim.Value); + var isActiveClaim = identity.FindFirst("is_active"); + Assert.NotNull(isActiveClaim); + Assert.Equal("False", isActiveClaim.Value); } [Fact] - public async Task CreateAsync_AddsTenantClaim_WithCustomClaimType() + public async Task GenerateClaims_WithCustomClaimType_EmitsTenantClaimUnderCustomKey() { const string customClaimType = "custom_tenant_claim"; - const string tenantId = "tenant-id-789"; - const string tenantIdentifier = "tenant-789"; - var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); - var customOptions = new IdmtOptions { MultiTenant = new MultiTenantOptions @@ -205,71 +186,44 @@ public async Task CreateAsync_AddsTenantClaim_WithCustomClaimType() var customOptionsMock = new Mock>(); customOptionsMock.Setup(x => x.Value).Returns(customOptions); - var customTenantStoreMock = new Mock>(); - customTenantStoreMock.Setup(x => x.GetAsync(tenantId)) - .ReturnsAsync(tenantInfo); + var customAccessor = new Mock(); + var tenant = new IdmtTenantInfo("tenant-id-789", "tenant-789", "Custom Tenant"); + customAccessor.SetupGet(x => x.MultiTenantContext) + .Returns(new MultiTenantContext(tenant)); var customFactory = new IdmtUserClaimsPrincipalFactory( _userManagerMock.Object, _roleManagerMock.Object, _identityOptionsMock.Object, - customTenantStoreMock.Object, + customAccessor.Object, customOptionsMock.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var user = new IdmtUser - { - Id = Guid.NewGuid(), - UserName = "testuser", - Email = "test@example.com", - TenantId = tenantId, - IsActive = true, - SecurityStamp = Guid.NewGuid().ToString(), - ConcurrencyStamp = Guid.NewGuid().ToString() - }; - - var customMethod = typeof(IdmtUserClaimsPrincipalFactory) + var user = BuildUser(); + var method = typeof(IdmtUserClaimsPrincipalFactory) .GetMethod("GenerateClaimsAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var identity = (ClaimsIdentity)await (dynamic)customMethod!.Invoke(customFactory, new object[] { user })!; + var identity = (ClaimsIdentity)await (dynamic)method!.Invoke(customFactory, new object[] { user })!; var tenantClaim = identity.FindFirst(customClaimType); Assert.NotNull(tenantClaim); - // The factory adds tenantInfo.Identifier, not tenantId - Assert.Equal(tenantIdentifier, tenantClaim.Value); + Assert.Equal("tenant-789", tenantClaim.Value); - // Verify default claim type is not present + // Default claim type must NOT be emitted when custom is configured. var defaultTenantClaim = identity.FindFirst(IdmtMultiTenantStrategy.DefaultClaim); Assert.Null(defaultTenantClaim); } [Fact] - public async Task CreateAsync_IncludesBaseClaims() + public async Task GenerateClaims_IncludesBaseClaims() { - const string tenantId = "tenant-id-123"; - const string tenantIdentifier = "tenant-123"; - var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); - - var userId = Guid.NewGuid(); - var user = new IdmtUser - { - Id = userId, - UserName = "testuser", - Email = "test@example.com", - TenantId = tenantId, - IsActive = true, - SecurityStamp = Guid.NewGuid().ToString(), - ConcurrencyStamp = Guid.NewGuid().ToString() - }; - - _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) - .ReturnsAsync(tenantInfo); + SetAmbientTenant("tenant-id-123", "tenant-123"); + var user = BuildUser(); var identity = await CallGenerateClaimsAsync(user); - // Verify base claims are present (from base.GenerateClaimsAsync) var nameIdentifierClaim = identity.FindFirst(ClaimTypes.NameIdentifier); Assert.NotNull(nameIdentifierClaim); - Assert.Equal(userId.ToString(), nameIdentifierClaim.Value); + Assert.Equal(user.Id.ToString(), nameIdentifierClaim.Value); var nameClaim = identity.FindFirst(ClaimTypes.Name); Assert.NotNull(nameClaim); @@ -277,37 +231,50 @@ public async Task CreateAsync_IncludesBaseClaims() } [Fact] - public async Task CreateAsync_AddsAllCustomClaims() + public async Task GenerateClaims_WithSysRoleSysAdmin_EmitsRoleClaim() { - const string tenantId = "tenant-id-999"; - const string tenantIdentifier = "tenant-999"; - var tenantInfo = new IdmtTenantInfo(tenantId, tenantIdentifier, "Test Tenant"); + SetAmbientTenant("tenant-id-123", "tenant-123"); + var user = BuildUser(SysRoleKind.SysAdmin); - var user = new IdmtUser - { - Id = Guid.NewGuid(), - UserName = "testuser", - Email = "test@example.com", - TenantId = tenantId, - IsActive = true, - SecurityStamp = Guid.NewGuid().ToString(), - ConcurrencyStamp = Guid.NewGuid().ToString() - }; + var identity = await CallGenerateClaimsAsync(user); + + var roleClaims = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); + Assert.Contains("SysAdmin", roleClaims); + } + + [Fact] + public async Task GenerateClaims_WithSysRoleSysSupport_EmitsRoleClaim() + { + SetAmbientTenant("tenant-id-123", "tenant-123"); + var user = BuildUser(SysRoleKind.SysSupport); - _tenantStoreMock.Setup(x => x.GetAsync(tenantId)) - .ReturnsAsync(tenantInfo); + var identity = await CallGenerateClaimsAsync(user); + + var roleClaims = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); + Assert.Contains("SysSupport", roleClaims); + } + + [Fact] + public async Task GenerateClaims_WithSysRoleNone_DoesNotEmitSysRoleClaim() + { + SetAmbientTenant("tenant-id-123", "tenant-123"); + var user = BuildUser(); var identity = await CallGenerateClaimsAsync(user); - // Verify both custom claims are present - var isActiveClaim = identity.FindFirst("is_active"); - Assert.NotNull(isActiveClaim); - Assert.Equal("True", isActiveClaim.Value); + var roleClaims = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(); + Assert.DoesNotContain("SysAdmin", roleClaims); + Assert.DoesNotContain("SysSupport", roleClaims); + Assert.DoesNotContain("None", roleClaims); + } - var tenantClaim = identity.FindFirst(IdmtMultiTenantStrategy.DefaultClaim); - Assert.NotNull(tenantClaim); - // The factory adds tenantInfo.Identifier, not tenantId - Assert.Equal(tenantIdentifier, tenantClaim.Value); + [Fact] + public async Task GenerateClaims_WithNullAmbientTenant_ThrowsInvalidOperationException() + { + SetAmbientTenantNull(); + var user = BuildUser(); + + var ex = await Assert.ThrowsAsync(() => CallGenerateClaimsAsync(user)); + Assert.Contains("ambient tenant context", ex.Message, StringComparison.OrdinalIgnoreCase); } } - diff --git a/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs b/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs index f686008..99d1825 100644 --- a/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs +++ b/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs @@ -198,4 +198,124 @@ public void CanManageUser_ReturnsTrue_WhenTenantAdminManagesTenantUser() Assert.True(result); } + + // ----------------------------------------------------------------------- + // Phase 1 / Step 10: locked decision #4 — TenantAccess gate is uniform. + // SysRole != None must NOT short-circuit the check. Even SysAdmin needs an + // active TenantAccess row to reach a tenant. These tests pin that invariant + // so that any future "fast-path" that re-introduces a SysRole bypass fails. + // ----------------------------------------------------------------------- + + [Fact] + public async Task CanAccessTenantAsync_SysAdminUser_NoTenantAccessRow_ReturnsFalse() + { + var userId = Guid.NewGuid(); + var tenantId = "tenant1"; + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "sysadmin@example.com", + Email = "sysadmin@example.com", + SysRole = SysRoleKind.SysAdmin + }); + await _dbContext.SaveChangesAsync(); + + var result = await _service.CanAccessTenantAsync(userId, tenantId); + + Assert.False(result); + } + + [Fact] + public async Task CanAccessTenantAsync_SysSupportUser_NoTenantAccessRow_ReturnsFalse() + { + var userId = Guid.NewGuid(); + var tenantId = "tenant1"; + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "syssupport@example.com", + Email = "syssupport@example.com", + SysRole = SysRoleKind.SysSupport + }); + await _dbContext.SaveChangesAsync(); + + var result = await _service.CanAccessTenantAsync(userId, tenantId); + + Assert.False(result); + } + + [Fact] + public async Task CanAccessTenantAsync_SysAdminUser_WithActiveTenantAccess_ReturnsTrue() + { + var userId = Guid.NewGuid(); + var tenantId = "tenant1"; + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "sysadmin@example.com", + Email = "sysadmin@example.com", + SysRole = SysRoleKind.SysAdmin + }); + _dbContext.TenantAccess.Add(new TenantAccess + { + UserId = userId, + TenantId = tenantId, + IsActive = true + }); + await _dbContext.SaveChangesAsync(); + + var result = await _service.CanAccessTenantAsync(userId, tenantId); + + // Uniform with normal users — SysAdmin gets through only because of the row. + Assert.True(result); + } + + [Fact] + public async Task CanAccessTenantAsync_NormalUser_WithActiveTenantAccess_ReturnsTrue() + { + var userId = Guid.NewGuid(); + var tenantId = "tenant1"; + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "user@example.com", + Email = "user@example.com", + SysRole = SysRoleKind.None + }); + _dbContext.TenantAccess.Add(new TenantAccess + { + UserId = userId, + TenantId = tenantId, + IsActive = true + }); + await _dbContext.SaveChangesAsync(); + + var result = await _service.CanAccessTenantAsync(userId, tenantId); + + Assert.True(result); + } + + [Fact] + public async Task CanAccessTenantAsync_NormalUser_NoTenantAccess_ReturnsFalse() + { + var userId = Guid.NewGuid(); + var tenantId = "tenant1"; + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "user@example.com", + Email = "user@example.com", + SysRole = SysRoleKind.None + }); + await _dbContext.SaveChangesAsync(); + + var result = await _service.CanAccessTenantAsync(userId, tenantId); + + Assert.False(result); + } } diff --git a/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs b/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs index 1acc661..e46977e 100644 --- a/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs +++ b/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs @@ -10,22 +10,32 @@ namespace Idmt.UnitTests.Services; public class TenantOperationServiceTests { private readonly Mock> _tenantStoreMock; - private readonly Mock _tenantContextSetterMock; + private readonly AsyncLocalMultiTenantContextAccessor _accessor; private readonly TenantOperationService _service; public TenantOperationServiceTests() { _tenantStoreMock = new Mock>(); - _tenantContextSetterMock = new Mock(); + _accessor = new AsyncLocalMultiTenantContextAccessor(); var services = new ServiceCollection(); services.AddSingleton(_tenantStoreMock.Object); - services.AddSingleton(_tenantContextSetterMock.Object); + services.AddSingleton(_accessor); + services.AddSingleton>(_accessor); + services.AddSingleton(_accessor); var serviceProvider = services.BuildServiceProvider(); _service = new TenantOperationService(serviceProvider); } + private static IdmtTenantInfo MakeTenant(string identifier, bool active = true) => + new(identifier, identifier, identifier) { IsActive = active }; + + private void SetOuterContext(IdmtTenantInfo tenant) + { + ((IMultiTenantContextSetter)_accessor).MultiTenantContext = new MultiTenantContext(tenant); + } + [Fact] public async Task ExecuteInTenantScopeAsync_ReturnsTenantNotFound_WhenTenantDoesNotExist() { @@ -42,7 +52,7 @@ public async Task ExecuteInTenantScopeAsync_ReturnsTenantNotFound_WhenTenantDoes [Fact] public async Task ExecuteInTenantScopeAsync_ReturnsTenantInactive_WhenRequireActiveAndTenantInactive() { - var tenant = new IdmtTenantInfo("inactive-tenant", "inactive-tenant", "Inactive") { IsActive = false }; + var tenant = MakeTenant("inactive-tenant", active: false); _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("inactive-tenant")) .ReturnsAsync(tenant); @@ -57,7 +67,7 @@ public async Task ExecuteInTenantScopeAsync_ReturnsTenantInactive_WhenRequireAct [Fact] public async Task ExecuteInTenantScopeAsync_AllowsExecution_WhenRequireActiveFalseAndTenantInactive() { - var tenant = new IdmtTenantInfo("inactive-tenant", "inactive-tenant", "Inactive") { IsActive = false }; + var tenant = MakeTenant("inactive-tenant", active: false); _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("inactive-tenant")) .ReturnsAsync(tenant); @@ -69,21 +79,113 @@ public async Task ExecuteInTenantScopeAsync_AllowsExecution_WhenRequireActiveFal } [Fact] - public async Task ExecuteInTenantScopeAsync_SetsTenantContext_BeforeCallingOperation() + public async Task ExecuteInTenantScopeAsync_SetsTargetTenantContext_DuringOperation() { - var tenant = new IdmtTenantInfo("test-tenant", "test-tenant", "Test") { IsActive = true }; + var tenant = MakeTenant("test-tenant"); _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("test-tenant")) .ReturnsAsync(tenant); - IMultiTenantContext? capturedContext = null; - _tenantContextSetterMock.SetupSet(x => x.MultiTenantContext = It.IsAny()) - .Callback(ctx => capturedContext = ctx); + string? observedIdentifier = null; + var result = await _service.ExecuteInTenantScopeAsync("test-tenant", _ => + { + observedIdentifier = _accessor.MultiTenantContext.TenantInfo?.Identifier; + return Task.FromResult>(Result.Success); + }); + + Assert.False(result.IsError); + Assert.Equal("test-tenant", observedIdentifier); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_RestoresPreviousContext_WhenDelegateSucceeds() + { + var outer = MakeTenant("outer"); + var target = MakeTenant("target"); + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("target")).ReturnsAsync(target); + SetOuterContext(outer); + + var result = await _service.ExecuteInTenantScopeAsync("target", + _ => Task.FromResult>(Result.Success)); + + Assert.False(result.IsError); + Assert.Equal("outer", _accessor.MultiTenantContext.TenantInfo?.Identifier); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_RestoresPreviousContext_WhenDelegateThrows() + { + var outer = MakeTenant("outer"); + var target = MakeTenant("target"); + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("target")).ReturnsAsync(target); + SetOuterContext(outer); + + await Assert.ThrowsAsync(async () => + { + await _service.ExecuteInTenantScopeAsync("target", _ => + throw new InvalidOperationException("boom")); + }); + + Assert.Equal("outer", _accessor.MultiTenantContext.TenantInfo?.Identifier); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_RestoresFromNullPreviousContext() + { + var target = MakeTenant("target"); + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("target")).ReturnsAsync(target); + // Outer context is the default empty MultiTenantContext — TenantInfo is null. - var result = await _service.ExecuteInTenantScopeAsync("test-tenant", + var result = await _service.ExecuteInTenantScopeAsync("target", _ => Task.FromResult>(Result.Success)); Assert.False(result.IsError); - Assert.NotNull(capturedContext); - Assert.Equal("test-tenant", capturedContext!.TenantInfo?.Identifier); + Assert.Null(_accessor.MultiTenantContext.TenantInfo); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_RestoresAcrossAsyncBoundary() + { + var outer = MakeTenant("outer"); + var target = MakeTenant("target"); + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("target")).ReturnsAsync(target); + SetOuterContext(outer); + + var result = await _service.ExecuteInTenantScopeAsync("target", async _ => + { + await Task.Yield(); + await Task.Delay(1); + return Result.Success; + }); + + Assert.False(result.IsError); + Assert.Equal("outer", _accessor.MultiTenantContext.TenantInfo?.Identifier); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_NestedCalls_RestoreEachLayer() + { + var outer = MakeTenant("tenant-a"); + var middle = MakeTenant("tenant-b"); + var inner = MakeTenant("tenant-c"); + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("tenant-b")).ReturnsAsync(middle); + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("tenant-c")).ReturnsAsync(inner); + SetOuterContext(outer); + + string? betweenNested = null; + + var result = await _service.ExecuteInTenantScopeAsync("tenant-b", async _ => + { + Assert.Equal("tenant-b", _accessor.MultiTenantContext.TenantInfo?.Identifier); + + await _service.ExecuteInTenantScopeAsync("tenant-c", + __ => Task.FromResult>(Result.Success)); + + betweenNested = _accessor.MultiTenantContext.TenantInfo?.Identifier; + return Result.Success; + }); + + Assert.False(result.IsError); + Assert.Equal("tenant-b", betweenNested); + Assert.Equal("tenant-a", _accessor.MultiTenantContext.TenantInfo?.Identifier); } } diff --git a/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs index 03f2b1c..efc77ca 100644 --- a/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs +++ b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs @@ -154,15 +154,65 @@ public void UpdateUserInfoRequestValidator_Passes_WhenAllFieldsNull() public void ConfirmEmailRequestValidator_Fails_WithEmptyFields() { var validator = new ConfirmEmailRequestValidator(); - var request = new ConfirmEmail.ConfirmEmailRequest("", "", ""); + var request = new ConfirmEmail.ConfirmEmailRequest("", ""); var result = validator.TestValidate(request); - result.ShouldHaveValidationErrorFor(x => x.TenantIdentifier); result.ShouldHaveValidationErrorFor(x => x.Email); result.ShouldHaveValidationErrorFor(x => x.Token); } #endregion + #region ConfirmEmailChangeRequestValidator + + [Fact] + public void ConfirmEmailChangeRequestValidator_Fails_WithEmptyFields() + { + var validator = new ConfirmEmailChangeRequestValidator(); + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest("", "", ""); + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Email); + result.ShouldHaveValidationErrorFor(x => x.NewEmail); + result.ShouldHaveValidationErrorFor(x => x.Token); + } + + [Fact] + public void ConfirmEmailChangeRequestValidator_Fails_WithInvalidEmail() + { + var validator = new ConfirmEmailChangeRequestValidator(); + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest("not-an-email", "new@example.com", "token"); + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Fact] + public void ConfirmEmailChangeRequestValidator_Fails_WithInvalidNewEmail() + { + var validator = new ConfirmEmailChangeRequestValidator(); + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest("user@example.com", "not-an-email", "token"); + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.NewEmail); + } + + [Fact] + public void ConfirmEmailChangeRequestValidator_Fails_WithEmptyToken() + { + var validator = new ConfirmEmailChangeRequestValidator(); + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest("user@example.com", "new@example.com", ""); + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Token); + } + + [Fact] + public void ConfirmEmailChangeRequestValidator_Passes_WithValidData() + { + var validator = new ConfirmEmailChangeRequestValidator(); + var request = new ConfirmEmailChange.ConfirmEmailChangeRequest("user@example.com", "new@example.com", "valid-token"); + var result = validator.TestValidate(request); + result.ShouldNotHaveAnyValidationErrors(); + } + + #endregion + #region ForgotPasswordRequestValidator [Fact] @@ -195,7 +245,7 @@ public void RefreshTokenRequestValidator_Fails_WithEmptyToken() public void ResetPasswordRequestValidator_Fails_WithWeakPassword() { var validator = new ResetPasswordRequestValidator(DefaultOptions()); - var request = new ResetPassword.ResetPasswordRequest("tenant1", "user@example.com", "valid-token", "weak"); + var request = new ResetPassword.ResetPasswordRequest("user@example.com", "valid-token", "weak"); var result = validator.TestValidate(request); result.ShouldHaveValidationErrorFor(x => x.NewPassword); } diff --git a/tools/Idmt.Migrator/Idmt.Migrator.csproj b/tools/Idmt.Migrator/Idmt.Migrator.csproj new file mode 100644 index 0000000..676714b --- /dev/null +++ b/tools/Idmt.Migrator/Idmt.Migrator.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + enable + enable + Idmt.Migrator + Idmt.Migrator + false + + + + + + + + + + + + + + + + + + diff --git a/tools/Idmt.Migrator/Program.cs b/tools/Idmt.Migrator/Program.cs new file mode 100644 index 0000000..0ebd323 --- /dev/null +++ b/tools/Idmt.Migrator/Program.cs @@ -0,0 +1,215 @@ +using Idmt.Plugin.Extensions; +using Idmt.Plugin.Migration; +using Idmt.Plugin.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Idmt.Migrator; + +/// +/// CLI host for . Documented harness — not a +/// hardened production tool. Wires the migrator against a consumer-supplied +/// appsettings.json + environment variables. +/// +internal static class Program +{ + private const string DryRunSwitch = "--dry-run"; + private const string ApplySwitch = "--apply"; + private const string AckSwitch = "--ack-dryrun-fingerprint"; + private const string AcceptCrossTenantSwitch = "--accept-cross-tenant-merges"; + private const string ProviderSwitch = "--provider"; + + public static async Task Main(string[] args) + { + var parsed = ParseArgs(args); + if (parsed is null) + { + PrintUsage(); + return 1; + } + + using var host = BuildHost(parsed.Provider); + var migrator = host.Services.GetRequiredService(); + var logger = host.Services.GetRequiredService>(); + + try + { + if (parsed.IsDryRun) + { + var report = await migrator.DryRunAsync(); + Console.WriteLine($"fingerprint={report.Fingerprint}"); + Console.WriteLine($"totalUsers={report.TotalUsers}"); + Console.WriteLine($"duplicateGroups={report.DuplicateGroups.Count}"); + foreach (var group in report.DuplicateGroups) + { + Console.WriteLine($" group email={group.NormalizedEmail} canonical={group.CanonicalUserId} duplicates={group.DuplicateUserIds.Length} sysRole={group.FoldedSysRole}"); + } + return 0; + } + + if (parsed.IsApply) + { + if (string.IsNullOrEmpty(parsed.AckFingerprint)) + { + Console.Error.WriteLine($"missing {AckSwitch}; refusing to apply."); + return 2; + } + + var report = await migrator.ApplyAsync(parsed.AckFingerprint, parsed.AcceptedCrossTenantGroupIds); + Console.WriteLine($"groupsProcessed={report.GroupsProcessed}"); + Console.WriteLine($"tenantAccessRewrites={report.Rewrites.TenantAccess}"); + Console.WriteLine($"auditRewrites={report.Rewrites.AuditLogs}"); + Console.WriteLine($"identityUserRoleRewrites={report.Rewrites.IdentityUserRoles}"); + Console.WriteLine($"identityUserTokenRewrites={report.Rewrites.IdentityUserTokens}"); + Console.WriteLine($"legacyRevocationsDeleted={report.Rewrites.LegacyRevocationsDeleted}"); + Console.WriteLine($"duplicatesDeleted={report.Rewrites.DuplicatesDeleted}"); + Console.WriteLine($"securityStampsRotated={report.StampsRotated}"); + return 0; + } + + PrintUsage(); + return 1; + } + catch (Exception ex) + { + logger.LogError(ex, "Migration failed"); + Console.Error.WriteLine($"error: {ex.Message}"); + return 3; + } + } + + private static IHost BuildHost(DatabaseProvider provider) + { + var builder = Host.CreateApplicationBuilder(); + + builder.Configuration + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables(prefix: "IDMT_"); + + // Override the default ICurrentUserService BEFORE AddIdmt registers the live one, by + // post-processing the resulting service collection. + builder.Services.AddIdmt( + builder.Configuration, + configureDb: options => + { + var connectionString = builder.Configuration.GetConnectionString("Idmt") + ?? throw new InvalidOperationException("ConnectionStrings:Idmt is not configured."); + switch (provider) + { + case DatabaseProvider.Sqlite: + options.UseSqlite(connectionString); + break; + case DatabaseProvider.SqlServer: + options.UseSqlServer(connectionString); + break; + default: + throw new InvalidOperationException($"Unsupported provider: {provider}"); + } + }); + + // Replace the scoped ICurrentUserService with the migration stub and register the + // migrator. The live ICurrentUserService expects an HTTP context; we have none. + builder.Services.AddIdmtMigration(); + + return builder.Build(); + } + + private static ParsedArgs? ParseArgs(string[] args) + { + if (args.Length == 0) + { + return null; + } + + var parsed = new ParsedArgs(); + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg) + { + case DryRunSwitch: + parsed.IsDryRun = true; + break; + case ApplySwitch: + parsed.IsApply = true; + break; + case AckSwitch when i + 1 < args.Length: + parsed.AckFingerprint = args[++i]; + break; + // Explicit "missing value" guard arms. Without these, a value-taking switch + // appearing as the trailing argument would fall through to the default + // "unknown argument" branch, which is misleading UX (the switch is known; + // its value is simply absent). + case AckSwitch: + Console.Error.WriteLine($"missing value for {AckSwitch}"); + return null; + case AcceptCrossTenantSwitch when i + 1 < args.Length: + parsed.AcceptedCrossTenantGroupIds = args[++i] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + break; + case AcceptCrossTenantSwitch: + Console.Error.WriteLine($"missing value for {AcceptCrossTenantSwitch}"); + return null; + case ProviderSwitch when i + 1 < args.Length: + if (!Enum.TryParse(args[++i], ignoreCase: true, out var prov)) + { + Console.Error.WriteLine($"invalid provider; expected one of: {string.Join(", ", Enum.GetNames())}"); + return null; + } + parsed.Provider = prov; + break; + case ProviderSwitch: + Console.Error.WriteLine($"missing value for {ProviderSwitch}"); + return null; + default: + Console.Error.WriteLine($"unknown argument: {arg}"); + return null; + } + } + + if (parsed.IsDryRun == parsed.IsApply) + { + // either both true or both false → invalid. + Console.Error.WriteLine($"specify exactly one of {DryRunSwitch} | {ApplySwitch}."); + return null; + } + + return parsed; + } + + private static void PrintUsage() + { + Console.Error.WriteLine($""" +Idmt.Migrator — canonical identity data migration tool (documented harness). + +Usage: + Idmt.Migrator {DryRunSwitch} [{ProviderSwitch} sqlite|sqlserver] + Idmt.Migrator {ApplySwitch} {AckSwitch} [{AcceptCrossTenantSwitch} ] [{ProviderSwitch} sqlite|sqlserver] + +Configuration: + ConnectionStrings:Idmt — required. Provide via appsettings.json or IDMT_ connection-string env var. +"""); + } + + private enum DatabaseProvider + { + SqlServer, + Sqlite, + } + + private sealed class ParsedArgs + { + public bool IsDryRun { get; set; } + public bool IsApply { get; set; } + public string? AckFingerprint { get; set; } + public string[] AcceptedCrossTenantGroupIds { get; set; } = []; + public DatabaseProvider Provider { get; set; } = DatabaseProvider.SqlServer; + } + + private sealed class MigratorMarker + { + } +}