From 9a90ddae6da329d7c9b67b2751587a76d98f58a2 Mon Sep 17 00:00:00 2001 From: idotta Date: Sun, 15 Mar 2026 01:09:26 -0300 Subject: [PATCH 01/19] Add unit tests for TenantAccessService, TenantOperationService, TokenRevocationCleanupService, TokenRevocationService, and validation logic - Implement tests for TenantAccessService covering access checks and role management. - Create tests for TenantOperationService to validate tenant scope execution. - Add tests for TokenRevocationCleanupService to ensure cleanup logic works as expected. - Develop comprehensive tests for TokenRevocationService to verify token revocation and cleanup behavior. - Introduce FluentValidatorTests and ValidatorsTests to validate request and password validation logic. --- .github/workflows/ci.yml | 12 +-- .github/workflows/publish.yml | 8 +- .../Configuration/IdmtEndpointNames.cs | 0 .../Configuration/IdmtOptions.cs | 2 +- .../Configuration/IdmtOptionsValidator.cs | 11 +-- .../Constants/AuditAction.cs | 0 .../Constants/IdmtClaimTypes.cs | 0 .../Errors/IdmtErrors.cs | 0 .../ApplicationBuilderExtensions.cs | 8 +- .../Extensions/ServiceCollectionExtensions.cs | 0 .../Features/Admin/AdminModels.cs | 0 .../Features/Admin/CreateTenant.cs | 3 +- .../Features/Admin/DeleteTenant.cs | 0 .../Features/Admin/GetAllTenants.cs | 0 .../Features/Admin/GetUserTenants.cs | 0 .../Features/Admin/GrantTenantAccess.cs | 5 +- .../Features/Admin/RevokeTenantAccess.cs | 4 + .../Features/AdminEndpoints.cs | 0 .../Features/Auth/ConfirmEmail.cs | 0 .../Features/Auth/DiscoverTenants.cs | 0 .../Features/Auth/ForgotPassword.cs | 0 .../Features/Auth/Login.cs | 0 .../Features/Auth/Logout.cs | 36 +++++--- .../Features/Auth/RefreshToken.cs | 0 .../Features/Auth/ResendConfirmationEmail.cs | 0 .../Features/Auth/ResetPassword.cs | 0 .../Features/AuthEndpoints.cs | 0 .../Features/Health/BasicHealthCheck.cs | 0 .../Features/Manage/GetUserInfo.cs | 0 .../Features/Manage/RegisterUser.cs | 0 .../Features/Manage/UnregisterUser.cs | 0 .../Features/Manage/UpdateUser.cs | 0 .../Features/Manage/UpdateUserInfo.cs | 8 ++ .../Features/ManageEndpoints.cs | 0 .../Idmt.Plugin.csproj | 2 +- .../Middleware/CurrentUserMiddleware.cs | 0 .../ValidateBearerTokenTenantMiddleware.cs | 0 .../Models/IAuditable.cs | 0 .../Models/IdmtAuditLog.cs | 0 .../Models/IdmtRole.cs | 0 .../Models/IdmtTenantInfo.cs | 0 .../Models/IdmtUser.cs | 0 .../Models/RevokedToken.cs | 0 .../Models/TenantAccess.cs | 0 .../Persistence/IdmtDbContext.cs | 0 .../Persistence/IdmtTenantStoreDbContext.cs | 0 .../Services/Base64Service.cs | 0 .../Services/CurrentUserService.cs | 0 .../Services/ICurrentUserService.cs | 0 .../Services/ITenantAccessService.cs | 0 .../Services/ITenantOperationService.cs | 0 .../Services/ITokenRevocationService.cs | 0 .../Services/IdmtEmailSender.cs | 0 .../Services/IdmtEmailSenderStartupCheck.cs | 0 .../Services/IdmtLinkGenerator.cs | 0 .../IdmtUserClaimsPrincipalFactory.cs | 0 .../Services/PiiMasker.cs | 0 .../Services/TenantAccessService.cs | 0 .../Services/TenantOperationService.cs | 0 .../Services/TokenRevocationCleanupService.cs | 0 .../Services/TokenRevocationService.cs | 0 .../ConfirmEmailRequestValidator.cs | 0 .../CreateTenantRequestValidator.cs | 0 .../DiscoverTenantsRequestValidator.cs | 0 .../ForgotPasswordRequestValidator.cs | 0 .../Validation/LoginRequestValidator.cs | 0 .../RefreshTokenRequestValidator.cs | 0 .../RegisterUserRequestValidator.cs | 0 ...ResendConfirmationEmailRequestValidator.cs | 0 .../ResetPasswordRequestValidator.cs | 0 .../UpdateUserInfoRequestValidator.cs | 0 .../Validation/ValidationHelper.cs | 0 .../Validation/Validators.cs | 0 src/Idmt.slnx => Idmt.slnx | 0 README.md | 25 +++--- .../Idmt.BasicSample/Idmt.BasicSample.csproj | 0 .../Idmt.BasicSample/Program.cs | 0 .../Properties/launchSettings.json | 0 .../Idmt.BasicSample/SeedTestUser.cs | 0 .../appsettings.Development.json | 0 .../Idmt.BasicSample/appsettings.json | 0 .../Idmt.BasicSample/wwwroot/README.md | 0 .../Idmt.BasicSample/wwwroot/css/styles.css | 0 .../Idmt.BasicSample/wwwroot/index.html | 0 .../Idmt.BasicSample/wwwroot/js/api-client.js | 0 .../AdminIntegrationTests.cs | 85 ++++++++++++++++++ .../AuthIntegrationTests.cs | 37 ++++++++ .../BaseIntegrationTest.cs | 0 .../HttpResponseExtensions.cs | 0 .../Idmt.BasicSample.Tests.csproj | 0 .../Idmt.BasicSample.Tests/IdmtApiFactory.cs | 0 .../ManageIntegrationTests.cs | 0 .../MultiTenancyIntegrationTests.cs | 22 +++++ .../IdmtOptionsValidatorTests.cs | 18 ++-- .../Configuration/RateLimitingOptionsTests.cs | 0 .../Admin/CreateTenantHandlerTests.cs | 0 .../Admin/DeleteTenantHandlerTests.cs | 0 .../Admin/GetAllTenantsHandlerTests.cs | 0 .../Admin/GetUserTenantsHandlerTests.cs | 0 .../Admin/GrantTenantAccessHandlerTests.cs | 0 .../Admin/RevokeTenantAccessHandlerTests.cs | 3 + .../Features/Auth/ConfirmEmailHandlerTests.cs | 0 .../Auth/DiscoverTenantsHandlerTests.cs | 0 .../Auth/ForgotPasswordHandlerTests.cs | 0 .../Features/Auth/LoginHandlerTests.cs | 0 .../Features/Auth/LogoutHandlerTests.cs | 90 ++++++++++++++----- .../Features/Auth/RefreshTokenHandlerTests.cs | 0 .../ResendConfirmationEmailHandlerTests.cs | 0 .../Auth/ResetPasswordHandlerTests.cs | 0 .../Features/Auth/TokenLoginHandlerTests.cs | 0 .../Features/Health/BasicHealthCheckTests.cs | 0 .../Manage/GetUserInfoHandlerTests.cs | 0 .../Features/Manage/RegisterHandlerTests.cs | 0 .../Features/Manage/UnregisterHandlerTests.cs | 0 .../Features/Manage/UpdateUserHandlerTests.cs | 0 .../Manage/UpdateUserInfoHandlerTests.cs | 6 ++ .../Idmt.UnitTests/Idmt.UnitTests.csproj | 0 .../Middleware/CurrentUserMiddlewareTests.cs | 0 ...alidateBearerTokenTenantMiddlewareTests.cs | 0 .../Models/IdmtTenantInfoTests.cs | 0 .../Services/CoreServicesTests.cs | 0 .../Services/IdmtLinkGeneratorTests.cs | 0 .../IdmtUserClaimsPrincipalFactoryTests.cs | 0 .../Services/TenantAccessServiceTests.cs | 0 .../Services/TenantOperationServiceTests.cs | 0 .../TokenRevocationCleanupServiceTests.cs | 0 .../Services/TokenRevocationServiceTests.cs | 0 .../Validation/FluentValidatorTests.cs | 0 .../Validation/ValidatorsTests.cs | 0 129 files changed, 312 insertions(+), 73 deletions(-) rename {src/Idmt.Plugin => Idmt.Plugin}/Configuration/IdmtEndpointNames.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Configuration/IdmtOptions.cs (99%) rename {src/Idmt.Plugin => Idmt.Plugin}/Configuration/IdmtOptionsValidator.cs (88%) rename {src/Idmt.Plugin => Idmt.Plugin}/Constants/AuditAction.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Constants/IdmtClaimTypes.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Errors/IdmtErrors.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Extensions/ApplicationBuilderExtensions.cs (95%) rename {src/Idmt.Plugin => Idmt.Plugin}/Extensions/ServiceCollectionExtensions.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/AdminModels.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/CreateTenant.cs (95%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/DeleteTenant.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/GetAllTenants.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/GetUserTenants.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/GrantTenantAccess.cs (97%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/RevokeTenantAccess.cs (94%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/AdminEndpoints.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ConfirmEmail.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/DiscoverTenants.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ForgotPassword.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/Login.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/Logout.cs (69%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/RefreshToken.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ResendConfirmationEmail.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ResetPassword.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/AuthEndpoints.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Health/BasicHealthCheck.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/GetUserInfo.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/RegisterUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/UnregisterUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/UpdateUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/UpdateUserInfo.cs (94%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/ManageEndpoints.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Idmt.Plugin.csproj (94%) rename {src/Idmt.Plugin => Idmt.Plugin}/Middleware/CurrentUserMiddleware.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Middleware/ValidateBearerTokenTenantMiddleware.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IAuditable.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtAuditLog.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtRole.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtTenantInfo.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/RevokedToken.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/TenantAccess.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Persistence/IdmtDbContext.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Persistence/IdmtTenantStoreDbContext.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/Base64Service.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/CurrentUserService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ICurrentUserService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ITenantAccessService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ITenantOperationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ITokenRevocationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtEmailSender.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtEmailSenderStartupCheck.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtLinkGenerator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtUserClaimsPrincipalFactory.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/PiiMasker.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TenantAccessService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TenantOperationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TokenRevocationCleanupService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TokenRevocationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ConfirmEmailRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/CreateTenantRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/DiscoverTenantsRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ForgotPasswordRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/LoginRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/RefreshTokenRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/RegisterUserRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ResendConfirmationEmailRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ResetPasswordRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/UpdateUserInfoRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ValidationHelper.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/Validators.cs (100%) rename src/Idmt.slnx => Idmt.slnx (100%) rename {src/samples => samples}/Idmt.BasicSample/Idmt.BasicSample.csproj (100%) rename {src/samples => samples}/Idmt.BasicSample/Program.cs (100%) rename {src/samples => samples}/Idmt.BasicSample/Properties/launchSettings.json (100%) rename {src/samples => samples}/Idmt.BasicSample/SeedTestUser.cs (100%) rename {src/samples => samples}/Idmt.BasicSample/appsettings.Development.json (100%) rename {src/samples => samples}/Idmt.BasicSample/appsettings.json (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/README.md (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/css/styles.css (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/index.html (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/js/api-client.js (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/AdminIntegrationTests.cs (83%) rename {src/tests => tests}/Idmt.BasicSample.Tests/AuthIntegrationTests.cs (93%) rename {src/tests => tests}/Idmt.BasicSample.Tests/BaseIntegrationTest.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/HttpResponseExtensions.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/IdmtApiFactory.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/ManageIntegrationTests.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs (93%) rename {src/tests => tests}/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs (94%) rename {src/tests => tests}/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs (95%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs (77%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs (97%) rename {src/tests => tests}/Idmt.UnitTests/Idmt.UnitTests.csproj (100%) rename {src/tests => tests}/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/CoreServicesTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TenantAccessServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TenantOperationServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Validation/FluentValidatorTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Validation/ValidatorsTests.cs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f1c84d..9dc4b46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,22 +20,22 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore src/Idmt.slnx + run: dotnet restore Idmt.slnx - name: Format check - run: dotnet format src/Idmt.slnx --verify-no-changes --verbosity diagnostic + run: dotnet format Idmt.slnx --verify-no-changes --verbosity diagnostic - name: Build - run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:TreatWarningsAsErrors=true + run: dotnet build Idmt.slnx --no-restore --configuration Release /p:TreatWarningsAsErrors=true - name: Run analyzers - run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true + run: dotnet build Idmt.slnx --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true - name: Test - run: dotnet test src/Idmt.slnx --no-build --configuration Release --verbosity normal + run: dotnet test Idmt.slnx --no-build --configuration Release --verbosity normal - name: Pack - run: dotnet pack src/Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . + run: dotnet pack Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . - name: Upload NuGet package uses: actions/upload-artifact@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 46e910b..617fc85 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,20 +16,20 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore src/Idmt.slnx + run: dotnet restore Idmt.slnx - name: Build - run: dotnet build src/Idmt.slnx --no-restore --configuration Release + run: dotnet build Idmt.slnx --no-restore --configuration Release - name: Test - run: dotnet test src/Idmt.slnx --no-build --configuration Release + run: dotnet test Idmt.slnx --no-build --configuration Release - name: Set Version id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Pack - run: dotnet pack src/Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . -p:PackageVersion=${{ env.VERSION }} --include-symbols -p:SymbolPackageFormat=snupkg + run: dotnet pack Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . -p:PackageVersion=${{ env.VERSION }} --include-symbols -p:SymbolPackageFormat=snupkg - name: Push to NuGet run: dotnet nuget push *.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} diff --git a/src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs b/Idmt.Plugin/Configuration/IdmtEndpointNames.cs similarity index 100% rename from src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs rename to Idmt.Plugin/Configuration/IdmtEndpointNames.cs diff --git a/src/Idmt.Plugin/Configuration/IdmtOptions.cs b/Idmt.Plugin/Configuration/IdmtOptions.cs similarity index 99% rename from src/Idmt.Plugin/Configuration/IdmtOptions.cs rename to Idmt.Plugin/Configuration/IdmtOptions.cs index d50b85e..58771c6 100644 --- a/src/Idmt.Plugin/Configuration/IdmtOptions.cs +++ b/Idmt.Plugin/Configuration/IdmtOptions.cs @@ -314,7 +314,7 @@ public class RateLimitingOptions public int PermitLimit { get; set; } = 10; /// - /// Duration of the sliding window in seconds. Default: 60. + /// Duration of the fixed window in seconds. Default: 60. /// public int WindowInSeconds { get; set; } = 60; } \ No newline at end of file diff --git a/src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs b/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs similarity index 88% rename from src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs rename to Idmt.Plugin/Configuration/IdmtOptionsValidator.cs index 6974467..1b62412 100644 --- a/src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs +++ b/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs @@ -34,13 +34,14 @@ private static void ValidateApplicationOptions(ApplicationOptions application, L "Use an empty string \"\" to disable the prefix or provide a value such as \"/api/v1\"."); } - // Rule 2: When EmailConfirmationMode is ClientForm, ClientUrl is required. - if (application.EmailConfirmationMode == EmailConfirmationMode.ClientForm && - string.IsNullOrWhiteSpace(application.ClientUrl)) + // Rule 2: ClientUrl is always required because password reset links always use + // client form URLs (GeneratePasswordResetLink), regardless of EmailConfirmationMode. + if (string.IsNullOrWhiteSpace(application.ClientUrl)) { failures.Add( - $"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ClientUrl)} must not be null or empty " + - $"when {nameof(ApplicationOptions.EmailConfirmationMode)} is {nameof(EmailConfirmationMode.ClientForm)}."); + $"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ClientUrl)} must not be null or empty. " + + "It is required for password reset links and for email confirmation when " + + $"{nameof(ApplicationOptions.EmailConfirmationMode)} is {nameof(EmailConfirmationMode.ClientForm)}."); } // Rule 3: When ClientUrl is set, the client-side form paths must also be configured. diff --git a/src/Idmt.Plugin/Constants/AuditAction.cs b/Idmt.Plugin/Constants/AuditAction.cs similarity index 100% rename from src/Idmt.Plugin/Constants/AuditAction.cs rename to Idmt.Plugin/Constants/AuditAction.cs diff --git a/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs b/Idmt.Plugin/Constants/IdmtClaimTypes.cs similarity index 100% rename from src/Idmt.Plugin/Constants/IdmtClaimTypes.cs rename to Idmt.Plugin/Constants/IdmtClaimTypes.cs diff --git a/src/Idmt.Plugin/Errors/IdmtErrors.cs b/Idmt.Plugin/Errors/IdmtErrors.cs similarity index 100% rename from src/Idmt.Plugin/Errors/IdmtErrors.cs rename to Idmt.Plugin/Errors/IdmtErrors.cs diff --git a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs b/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs similarity index 95% rename from src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs rename to Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs index 7a2ba63..a26b176 100644 --- a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs +++ b/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs @@ -173,9 +173,15 @@ private static async Task SeedDefaultDataAsync(IServiceProvider services) { var options = services.GetRequiredService>(); var createTenantHandler = services.GetRequiredService(); - await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( + var result = await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( MultiTenantOptions.DefaultTenantIdentifier, options.Value.MultiTenant.DefaultTenantName)); + + if (result.IsError && result.FirstError.Code != "Tenant.AlreadyExists") + { + throw new InvalidOperationException( + $"Failed to seed default tenant '{MultiTenantOptions.DefaultTenantIdentifier}': {result.FirstError.Description}"); + } } private static void VerifyUserStoreSupportsEmail(IApplicationBuilder app) diff --git a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs rename to Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs diff --git a/src/Idmt.Plugin/Features/Admin/AdminModels.cs b/Idmt.Plugin/Features/Admin/AdminModels.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/AdminModels.cs rename to Idmt.Plugin/Features/Admin/AdminModels.cs diff --git a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs b/Idmt.Plugin/Features/Admin/CreateTenant.cs similarity index 95% rename from src/Idmt.Plugin/Features/Admin/CreateTenant.cs rename to Idmt.Plugin/Features/Admin/CreateTenant.cs index 4b73a52..60f34c7 100644 --- a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs +++ b/Idmt.Plugin/Features/Admin/CreateTenant.cs @@ -151,7 +151,8 @@ public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBui _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError), }; } - return TypedResults.Created($"/admin/tenants/{response.Value.Identifier}", response.Value); + var apiPrefix = context.RequestServices.GetRequiredService>().Value.Application.ApiPrefix ?? string.Empty; + return TypedResults.Created($"{apiPrefix}/admin/tenants/{response.Value.Identifier}", response.Value); }) .WithSummary("Create Tenant") .WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant"); diff --git a/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs b/Idmt.Plugin/Features/Admin/DeleteTenant.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/DeleteTenant.cs rename to Idmt.Plugin/Features/Admin/DeleteTenant.cs diff --git a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs b/Idmt.Plugin/Features/Admin/GetAllTenants.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/GetAllTenants.cs rename to Idmt.Plugin/Features/Admin/GetAllTenants.cs diff --git a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs b/Idmt.Plugin/Features/Admin/GetUserTenants.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/GetUserTenants.cs rename to Idmt.Plugin/Features/Admin/GetUserTenants.cs diff --git a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs similarity index 97% rename from src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs rename to Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index 7c6e507..8cffe29 100644 --- a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -120,8 +120,9 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti Email = user.Email, EmailConfirmed = user.EmailConfirmed, PasswordHash = user.PasswordHash, - SecurityStamp = user.SecurityStamp, - ConcurrencyStamp = user.ConcurrencyStamp, + // 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, diff --git a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs similarity index 94% rename from src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs rename to Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index 839f04e..1cd5acd 100644 --- a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -31,6 +31,7 @@ internal sealed class RevokeTenantAccessHandler( IdmtDbContext dbContext, IMultiTenantStore tenantStore, ITenantOperationService tenantOps, + ITokenRevocationService tokenRevocationService, ILogger logger) : IRevokeTenantAccessHandler { public async Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default) @@ -61,6 +62,9 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti 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); } catch (Exception ex) { diff --git a/src/Idmt.Plugin/Features/AdminEndpoints.cs b/Idmt.Plugin/Features/AdminEndpoints.cs similarity index 100% rename from src/Idmt.Plugin/Features/AdminEndpoints.cs rename to Idmt.Plugin/Features/AdminEndpoints.cs diff --git a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs b/Idmt.Plugin/Features/Auth/ConfirmEmail.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs rename to Idmt.Plugin/Features/Auth/ConfirmEmail.cs diff --git a/src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs b/Idmt.Plugin/Features/Auth/DiscoverTenants.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs rename to Idmt.Plugin/Features/Auth/DiscoverTenants.cs diff --git a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs b/Idmt.Plugin/Features/Auth/ForgotPassword.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ForgotPassword.cs rename to Idmt.Plugin/Features/Auth/ForgotPassword.cs diff --git a/src/Idmt.Plugin/Features/Auth/Login.cs b/Idmt.Plugin/Features/Auth/Login.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/Login.cs rename to Idmt.Plugin/Features/Auth/Login.cs diff --git a/src/Idmt.Plugin/Features/Auth/Logout.cs b/Idmt.Plugin/Features/Auth/Logout.cs similarity index 69% rename from src/Idmt.Plugin/Features/Auth/Logout.cs rename to Idmt.Plugin/Features/Auth/Logout.cs index 28da8bb..400cda3 100644 --- a/src/Idmt.Plugin/Features/Auth/Logout.cs +++ b/Idmt.Plugin/Features/Auth/Logout.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -27,6 +28,7 @@ internal sealed class LogoutHandler( SignInManager signInManager, ICurrentUserService currentUserService, IMultiTenantContextAccessor tenantContextAccessor, + IMultiTenantStore tenantStore, IOptions idmtOptions, ITokenRevocationService tokenRevocationService) : ILogoutHandler @@ -48,21 +50,33 @@ public async Task> HandleAsync(CancellationToken cancellationTo else { // Fallback: the multi-tenant strategy did not resolve a tenant context - // (e.g. header or route strategies at logout time). Read the tenant claim - // from the bearer principal to produce a meaningful diagnostic. Token - // revocation cannot proceed without the tenant DB Id, so log a warning - // rather than silently succeeding with an unrevoked token. + // (e.g. header or route strategies at logout time). Extract the tenant + // identifier from the bearer principal's claims and resolve the tenant + // via the store so revocation can still proceed. var tenantClaimKey = idmtOptions.Value.MultiTenant.StrategyOptions .GetValueOrDefault(IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim); var tenantIdentifierFromClaim = currentUserService.User?.FindFirst(tenantClaimKey)?.Value; - logger.LogWarning( - "Token revocation skipped for user {UserId}: tenant context could not be resolved. " + - "Tenant identifier from bearer claims: {TenantIdentifier}. " + - "Ensure the multi-tenant strategy resolves during logout requests, " + - "or add the claim strategy so the tenant can be resolved from the bearer token.", - userId, - tenantIdentifierFromClaim ?? ""); + if (tenantIdentifierFromClaim is not null) + { + var resolvedTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifierFromClaim); + if (resolvedTenant?.Id is not null) + { + await tokenRevocationService.RevokeUserTokensAsync(userId, resolvedTenant.Id, cancellationToken); + } + else + { + logger.LogWarning( + "Token revocation skipped for user {UserId}: tenant identifier {TenantIdentifier} from bearer claims could not be resolved.", + userId, tenantIdentifierFromClaim); + } + } + else + { + logger.LogWarning( + "Token revocation skipped for user {UserId}: no tenant context resolved and no tenant claim present in bearer token.", + userId); + } } } diff --git a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs b/Idmt.Plugin/Features/Auth/RefreshToken.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/RefreshToken.cs rename to Idmt.Plugin/Features/Auth/RefreshToken.cs diff --git a/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs b/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs rename to Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs diff --git a/src/Idmt.Plugin/Features/Auth/ResetPassword.cs b/Idmt.Plugin/Features/Auth/ResetPassword.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ResetPassword.cs rename to Idmt.Plugin/Features/Auth/ResetPassword.cs diff --git a/src/Idmt.Plugin/Features/AuthEndpoints.cs b/Idmt.Plugin/Features/AuthEndpoints.cs similarity index 100% rename from src/Idmt.Plugin/Features/AuthEndpoints.cs rename to Idmt.Plugin/Features/AuthEndpoints.cs diff --git a/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs b/Idmt.Plugin/Features/Health/BasicHealthCheck.cs similarity index 100% rename from src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs rename to Idmt.Plugin/Features/Health/BasicHealthCheck.cs diff --git a/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs b/Idmt.Plugin/Features/Manage/GetUserInfo.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/GetUserInfo.cs rename to Idmt.Plugin/Features/Manage/GetUserInfo.cs diff --git a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs b/Idmt.Plugin/Features/Manage/RegisterUser.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/RegisterUser.cs rename to Idmt.Plugin/Features/Manage/RegisterUser.cs diff --git a/src/Idmt.Plugin/Features/Manage/UnregisterUser.cs b/Idmt.Plugin/Features/Manage/UnregisterUser.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/UnregisterUser.cs rename to Idmt.Plugin/Features/Manage/UnregisterUser.cs diff --git a/src/Idmt.Plugin/Features/Manage/UpdateUser.cs b/Idmt.Plugin/Features/Manage/UpdateUser.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/UpdateUser.cs rename to Idmt.Plugin/Features/Manage/UpdateUser.cs diff --git a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs b/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs similarity index 94% rename from src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs rename to Idmt.Plugin/Features/Manage/UpdateUserInfo.cs index 28b45b3..ed1aeaf 100644 --- a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs +++ b/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs @@ -35,6 +35,8 @@ internal sealed class UpdateUserInfoHandler( IdmtDbContext dbContext, IIdmtLinkGenerator linkGenerator, IEmailSender emailSender, + ICurrentUserService currentUserService, + ITokenRevocationService tokenRevocationService, ILogger logger) : IUpdateUserInfoHandler { public async Task> HandleAsync( @@ -101,6 +103,12 @@ public async Task> HandleAsync( var confirmLink = linkGenerator.GenerateConfirmEmailLink(request.NewEmail, confirmToken); await emailSender.SendConfirmationLinkAsync(appUser, request.NewEmail, confirmLink); + // 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); + } + 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 diff --git a/src/Idmt.Plugin/Features/ManageEndpoints.cs b/Idmt.Plugin/Features/ManageEndpoints.cs similarity index 100% rename from src/Idmt.Plugin/Features/ManageEndpoints.cs rename to Idmt.Plugin/Features/ManageEndpoints.cs diff --git a/src/Idmt.Plugin/Idmt.Plugin.csproj b/Idmt.Plugin/Idmt.Plugin.csproj similarity index 94% rename from src/Idmt.Plugin/Idmt.Plugin.csproj rename to Idmt.Plugin/Idmt.Plugin.csproj index fc5031e..8ffe614 100644 --- a/src/Idmt.Plugin/Idmt.Plugin.csproj +++ b/Idmt.Plugin/Idmt.Plugin.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs b/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs similarity index 100% rename from src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs rename to Idmt.Plugin/Middleware/CurrentUserMiddleware.cs diff --git a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs b/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs similarity index 100% rename from src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs rename to Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs diff --git a/src/Idmt.Plugin/Models/IAuditable.cs b/Idmt.Plugin/Models/IAuditable.cs similarity index 100% rename from src/Idmt.Plugin/Models/IAuditable.cs rename to Idmt.Plugin/Models/IAuditable.cs diff --git a/src/Idmt.Plugin/Models/IdmtAuditLog.cs b/Idmt.Plugin/Models/IdmtAuditLog.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtAuditLog.cs rename to Idmt.Plugin/Models/IdmtAuditLog.cs diff --git a/src/Idmt.Plugin/Models/IdmtRole.cs b/Idmt.Plugin/Models/IdmtRole.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtRole.cs rename to Idmt.Plugin/Models/IdmtRole.cs diff --git a/src/Idmt.Plugin/Models/IdmtTenantInfo.cs b/Idmt.Plugin/Models/IdmtTenantInfo.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtTenantInfo.cs rename to Idmt.Plugin/Models/IdmtTenantInfo.cs diff --git a/src/Idmt.Plugin/Models/IdmtUser.cs b/Idmt.Plugin/Models/IdmtUser.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtUser.cs rename to Idmt.Plugin/Models/IdmtUser.cs diff --git a/src/Idmt.Plugin/Models/RevokedToken.cs b/Idmt.Plugin/Models/RevokedToken.cs similarity index 100% rename from src/Idmt.Plugin/Models/RevokedToken.cs rename to Idmt.Plugin/Models/RevokedToken.cs diff --git a/src/Idmt.Plugin/Models/TenantAccess.cs b/Idmt.Plugin/Models/TenantAccess.cs similarity index 100% rename from src/Idmt.Plugin/Models/TenantAccess.cs rename to Idmt.Plugin/Models/TenantAccess.cs diff --git a/src/Idmt.Plugin/Persistence/IdmtDbContext.cs b/Idmt.Plugin/Persistence/IdmtDbContext.cs similarity index 100% rename from src/Idmt.Plugin/Persistence/IdmtDbContext.cs rename to Idmt.Plugin/Persistence/IdmtDbContext.cs diff --git a/src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs b/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs similarity index 100% rename from src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs rename to Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs diff --git a/src/Idmt.Plugin/Services/Base64Service.cs b/Idmt.Plugin/Services/Base64Service.cs similarity index 100% rename from src/Idmt.Plugin/Services/Base64Service.cs rename to Idmt.Plugin/Services/Base64Service.cs diff --git a/src/Idmt.Plugin/Services/CurrentUserService.cs b/Idmt.Plugin/Services/CurrentUserService.cs similarity index 100% rename from src/Idmt.Plugin/Services/CurrentUserService.cs rename to Idmt.Plugin/Services/CurrentUserService.cs diff --git a/src/Idmt.Plugin/Services/ICurrentUserService.cs b/Idmt.Plugin/Services/ICurrentUserService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ICurrentUserService.cs rename to Idmt.Plugin/Services/ICurrentUserService.cs diff --git a/src/Idmt.Plugin/Services/ITenantAccessService.cs b/Idmt.Plugin/Services/ITenantAccessService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ITenantAccessService.cs rename to Idmt.Plugin/Services/ITenantAccessService.cs diff --git a/src/Idmt.Plugin/Services/ITenantOperationService.cs b/Idmt.Plugin/Services/ITenantOperationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ITenantOperationService.cs rename to Idmt.Plugin/Services/ITenantOperationService.cs diff --git a/src/Idmt.Plugin/Services/ITokenRevocationService.cs b/Idmt.Plugin/Services/ITokenRevocationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ITokenRevocationService.cs rename to Idmt.Plugin/Services/ITokenRevocationService.cs diff --git a/src/Idmt.Plugin/Services/IdmtEmailSender.cs b/Idmt.Plugin/Services/IdmtEmailSender.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtEmailSender.cs rename to Idmt.Plugin/Services/IdmtEmailSender.cs diff --git a/src/Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs b/Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs rename to Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs diff --git a/src/Idmt.Plugin/Services/IdmtLinkGenerator.cs b/Idmt.Plugin/Services/IdmtLinkGenerator.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtLinkGenerator.cs rename to Idmt.Plugin/Services/IdmtLinkGenerator.cs diff --git a/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs b/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs rename to Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs diff --git a/src/Idmt.Plugin/Services/PiiMasker.cs b/Idmt.Plugin/Services/PiiMasker.cs similarity index 100% rename from src/Idmt.Plugin/Services/PiiMasker.cs rename to Idmt.Plugin/Services/PiiMasker.cs diff --git a/src/Idmt.Plugin/Services/TenantAccessService.cs b/Idmt.Plugin/Services/TenantAccessService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TenantAccessService.cs rename to Idmt.Plugin/Services/TenantAccessService.cs diff --git a/src/Idmt.Plugin/Services/TenantOperationService.cs b/Idmt.Plugin/Services/TenantOperationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TenantOperationService.cs rename to Idmt.Plugin/Services/TenantOperationService.cs diff --git a/src/Idmt.Plugin/Services/TokenRevocationCleanupService.cs b/Idmt.Plugin/Services/TokenRevocationCleanupService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TokenRevocationCleanupService.cs rename to Idmt.Plugin/Services/TokenRevocationCleanupService.cs diff --git a/src/Idmt.Plugin/Services/TokenRevocationService.cs b/Idmt.Plugin/Services/TokenRevocationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TokenRevocationService.cs rename to Idmt.Plugin/Services/TokenRevocationService.cs diff --git a/src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs b/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs rename to Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs b/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs rename to Idmt.Plugin/Validation/CreateTenantRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs b/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs rename to Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs b/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs rename to Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/LoginRequestValidator.cs b/Idmt.Plugin/Validation/LoginRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/LoginRequestValidator.cs rename to Idmt.Plugin/Validation/LoginRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs b/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs rename to Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs b/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs rename to Idmt.Plugin/Validation/RegisterUserRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs b/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs rename to Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs b/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs rename to Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs b/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs rename to Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ValidationHelper.cs b/Idmt.Plugin/Validation/ValidationHelper.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ValidationHelper.cs rename to Idmt.Plugin/Validation/ValidationHelper.cs diff --git a/src/Idmt.Plugin/Validation/Validators.cs b/Idmt.Plugin/Validation/Validators.cs similarity index 100% rename from src/Idmt.Plugin/Validation/Validators.cs rename to Idmt.Plugin/Validation/Validators.cs diff --git a/src/Idmt.slnx b/Idmt.slnx similarity index 100% rename from src/Idmt.slnx rename to Idmt.slnx diff --git a/README.md b/README.md index 827494e..02f71de 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Rate-limited. All endpoints are public except `/auth/logout`. | POST | /auth/resend-confirmation-email | - | Resend the confirmation email. | | POST | /auth/forgot-password | - | Send a password reset email. | | POST | /auth/reset-password | - | Reset password with a Base64URL-encoded token. | +| POST | /auth/discover-tenants | - | Discover tenants associated with an email address. Accepts `{ email }` and returns a tenant list. | Login requests accept `email` or `username`, `password`, `rememberMe`, and optionally `twoFactorCode` / `twoFactorRecoveryCode`. @@ -143,11 +144,11 @@ All endpoints require authentication. | Method | Path | Policy | Description | |--------|------|--------|-------------| -| GET | /manage/info | Authenticated | Get the current user's profile. | -| PUT | /manage/info | Authenticated | Update profile, email, or password. | -| POST | /manage/users | TenantManager | Register a new user (invite flow — sends password-setup email). | -| PUT | /manage/users/{id} | TenantManager | Activate or deactivate a user. | -| DELETE | /manage/users/{id} | TenantManager | Soft-delete a user. | +| GET | /manage/info | Default (authenticated) | Get the current user's profile. | +| PUT | /manage/info | Default (authenticated) | Update profile, email, or password. | +| POST | /manage/users | RequireTenantManager | Register a new user (invite flow — sends password-setup email). | +| PUT | /manage/users/{userId:guid} | RequireTenantManager | Activate or deactivate a user. | +| DELETE | /manage/users/{userId:guid} | RequireTenantManager | Delete a user. | ### Administration — `/admin` @@ -156,11 +157,11 @@ All endpoints require the `RequireSysUser` policy (`SysAdmin` or `SysSupport` ro | Method | Path | Description | |--------|------|-------------| | POST | /admin/tenants | Create a new tenant. | -| DELETE | /admin/tenants/{identifier} | Soft-delete a tenant. | +| DELETE | /admin/tenants/{tenantIdentifier} | Soft-delete a tenant. | | GET | /admin/tenants | List all tenants (paginated; query params: `page`, `pageSize`, max 100). | -| GET | /admin/users/{id}/tenants | List tenants accessible by a user. | -| POST | /admin/users/{id}/tenants/{identifier} | Grant a user access to a tenant. | -| DELETE | /admin/users/{id}/tenants/{identifier} | Revoke a user's access to a tenant. | +| GET | /admin/users/{userId:guid}/tenants | List tenants accessible by a user. | +| POST | /admin/users/{userId:guid}/tenants/{tenantIdentifier} | Grant a user access to a tenant. | +| DELETE | /admin/users/{userId:guid}/tenants/{tenantIdentifier} | Revoke a user's access to a tenant. | ### Health — `/healthz` @@ -175,6 +176,8 @@ Requires `RequireSysUser`. Returns database connectivity status via ASP.NET Core | `RequireSysAdmin` | SysAdmin | | `RequireSysUser` | SysAdmin, SysSupport | | `RequireTenantManager` | SysAdmin, SysSupport, TenantAdmin | +| `CookieOnly` | — (requires cookie authentication scheme) | +| `BearerOnly` | — (requires bearer authentication scheme) | Default roles seeded at startup: `SysAdmin`, `SysSupport`, `TenantAdmin`. Add custom roles via `Identity.ExtraRoles` in configuration. @@ -233,11 +236,11 @@ builder.Services.AddIdmt( { options.Application.ApiPrefix = "/api/v2"; }, - customizeAuth: auth => + customizeAuthentication: auth => { // Add additional authentication schemes }, - customizeAuthz: authz => + customizeAuthorization: authz => { // Add additional authorization policies } diff --git a/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj b/samples/Idmt.BasicSample/Idmt.BasicSample.csproj similarity index 100% rename from src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj rename to samples/Idmt.BasicSample/Idmt.BasicSample.csproj diff --git a/src/samples/Idmt.BasicSample/Program.cs b/samples/Idmt.BasicSample/Program.cs similarity index 100% rename from src/samples/Idmt.BasicSample/Program.cs rename to samples/Idmt.BasicSample/Program.cs diff --git a/src/samples/Idmt.BasicSample/Properties/launchSettings.json b/samples/Idmt.BasicSample/Properties/launchSettings.json similarity index 100% rename from src/samples/Idmt.BasicSample/Properties/launchSettings.json rename to samples/Idmt.BasicSample/Properties/launchSettings.json diff --git a/src/samples/Idmt.BasicSample/SeedTestUser.cs b/samples/Idmt.BasicSample/SeedTestUser.cs similarity index 100% rename from src/samples/Idmt.BasicSample/SeedTestUser.cs rename to samples/Idmt.BasicSample/SeedTestUser.cs diff --git a/src/samples/Idmt.BasicSample/appsettings.Development.json b/samples/Idmt.BasicSample/appsettings.Development.json similarity index 100% rename from src/samples/Idmt.BasicSample/appsettings.Development.json rename to samples/Idmt.BasicSample/appsettings.Development.json diff --git a/src/samples/Idmt.BasicSample/appsettings.json b/samples/Idmt.BasicSample/appsettings.json similarity index 100% rename from src/samples/Idmt.BasicSample/appsettings.json rename to samples/Idmt.BasicSample/appsettings.json diff --git a/src/samples/Idmt.BasicSample/wwwroot/README.md b/samples/Idmt.BasicSample/wwwroot/README.md similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/README.md rename to samples/Idmt.BasicSample/wwwroot/README.md diff --git a/src/samples/Idmt.BasicSample/wwwroot/css/styles.css b/samples/Idmt.BasicSample/wwwroot/css/styles.css similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/css/styles.css rename to samples/Idmt.BasicSample/wwwroot/css/styles.css diff --git a/src/samples/Idmt.BasicSample/wwwroot/index.html b/samples/Idmt.BasicSample/wwwroot/index.html similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/index.html rename to samples/Idmt.BasicSample/wwwroot/index.html diff --git a/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js b/samples/Idmt.BasicSample/wwwroot/js/api-client.js similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/js/api-client.js rename to samples/Idmt.BasicSample/wwwroot/js/api-client.js diff --git a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs similarity index 83% rename from src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 8aef208..815bb6d 100644 --- a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -1,8 +1,12 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using Idmt.Plugin.Features.Admin; +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; @@ -471,6 +475,87 @@ public async Task DeleteTenant_ReturnsForbidden_WhenDeletingDefaultTenant() #endregion + #region Revoke Tenant Access Security Tests + + [Fact] + public async Task RevokeTenantAccess_RefreshToken_IsRejectedAfterRevocation() + { + // Clean up any revoked tokens from other tests + using (var scope = Factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.RevokedTokens.ExecuteDeleteAsync(); + } + + var sysClient = await CreateAuthenticatedClientAsync(); + + // Create a second tenant + var secondTenantIdentifier = $"revoke-rt-{Guid.NewGuid():N}"; + var createTenantResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = secondTenantIdentifier, + Name = "Revoke RT Tenant" + }); + await createTenantResponse.AssertSuccess(); + + // Register a user in the system tenant + var email = $"revoke-rt-{Guid.NewGuid():N}@example.com"; + var password = "RevokeRT1!"; + var (userId, _) = await RegisterAndSetPasswordAsync(sysClient, password, email: email, username: $"revokert{Guid.NewGuid():N}"); + + // Grant user access to the second tenant + var grantResponse = await sysClient.PostAsJsonAsync( + $"/admin/users/{userId}/tenants/{secondTenantIdentifier}", + new { ExpiresAt = (DateTime?)null }); + await grantResponse.AssertSuccess(); + + // Login as that user to the second tenant + var tenantClient = Factory.CreateClientWithTenant(secondTenantIdentifier); + var loginResponse = await tenantClient.PostAsJsonAsync("/auth/token", new + { + Email = email, + Password = password + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + + tenantClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + // Verify refresh token works before revocation + var refreshBefore = await tenantClient.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(tokens.RefreshToken!)); + await refreshBefore.AssertSuccess(); + var refreshedTokens = await refreshBefore.Content.ReadFromJsonAsync(); + + // Revoke access to the second tenant + var revokeResponse = await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{secondTenantIdentifier}"); + Assert.Equal(HttpStatusCode.NoContent, revokeResponse.StatusCode); + + // Assert refresh token for that tenant now fails + var refreshAfter = await tenantClient.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(refreshedTokens!.RefreshToken!)); + Assert.False(refreshAfter.IsSuccessStatusCode); + } + + #endregion + + #region Default Tenant Tests + + [Fact] + public async Task DefaultTenant_ExistsAfterStartup() + { + var sysClient = await CreateAuthenticatedClientAsync(); + + // The GET /admin/tenants endpoint filters out the system tenant, + // so we verify the system tenant exists by checking the tenant store directly + using var scope = Factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier); + + Assert.NotNull(tenant); + Assert.Equal(IdmtApiFactory.DefaultTenantIdentifier, tenant!.Identifier); + } + + #endregion + #region Grant Tenant Access Validation Tests [Fact] diff --git a/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs similarity index 93% rename from src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs index 0708dd8..b1cd248 100644 --- a/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs @@ -344,6 +344,43 @@ public async Task Logout_WithoutAuthentication_Returns401() Assert.Contains(logoutResponse.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.Found }); } + [Fact] + public async Task Logout_Bearer_RefreshToken_IsRejectedAfterLogout() + { + // Clean up any revoked tokens from other tests + using (var scope = Factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.RevokedTokens.ExecuteDeleteAsync(); + } + + var client = Factory.CreateClientWithTenant(); + + // Login to get access + refresh tokens + var loginResponse = await client.PostAsJsonAsync("/auth/token", new + { + Email = IdmtApiFactory.SysAdminEmail, + Password = IdmtApiFactory.SysAdminPassword + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + // Verify refresh works before logout + var refreshBeforeLogout = await client.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(tokens.RefreshToken!)); + await refreshBeforeLogout.AssertSuccess(); + var refreshedTokens = await refreshBeforeLogout.Content.ReadFromJsonAsync(); + + // Logout with Bearer token + var logoutResponse = await client.PostAsync("/auth/logout", content: null); + Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); + + // Assert refresh now fails after logout + var refreshAfterLogout = await client.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(refreshedTokens!.RefreshToken!)); + Assert.False(refreshAfterLogout.IsSuccessStatusCode); + } + #endregion #region Confirm Email Tests diff --git a/src/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs b/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs rename to tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs diff --git a/src/tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs b/tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs rename to tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs diff --git a/src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj b/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj rename to tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj diff --git a/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs rename to tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs diff --git a/src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs diff --git a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs similarity index 93% rename from src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index ca9afa3..2a00b28 100644 --- a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -212,6 +212,28 @@ public async Task User_in_other_tenant_cannot_access_protected_endpoint_for_curr Assert.Contains(infoResponseB.StatusCode, new[] { HttpStatusCode.NotFound, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } + [Fact] + public async Task RefreshToken_FromTenantA_IsRejected_ByTenantB() + { + await EnsureTenantsExistAsync(); + + // Create a user in Tenant A + var email = $"refresh-cross-{Guid.NewGuid():N}@example.com"; + var password = "CrossTenant1!"; + await CreateUserInTenantAsync(TenantA, email, password); + + // Login to Tenant A to get tokens + var clientA = Factory.CreateClientWithTenant(TenantA); + var loginResponse = await clientA.PostAsJsonAsync("/auth/token", new { Email = email, Password = password }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + + // Try to refresh with Tenant B header - should fail + var clientB = Factory.CreateClientWithTenant(TenantB); + var refreshResponse = await clientB.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(tokens!.RefreshToken!)); + Assert.False(refreshResponse.IsSuccessStatusCode); + } + #endregion #region Route Strategy Tests diff --git a/src/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs b/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs similarity index 94% rename from src/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs rename to tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs index fb8b4e7..d93115a 100644 --- a/src/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs +++ b/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs @@ -122,16 +122,17 @@ public void Validate_Fails_WhenClientFormModeAndClientUrlIsWhitespace() } [Fact] - public void Validate_Succeeds_WhenServerConfirmModeAndClientUrlIsNull() + public void Validate_Fails_WhenServerConfirmModeAndClientUrlIsNull() { - // ClientUrl is only required for ClientForm mode; ServerConfirm does not need it. + // ClientUrl is always required because password reset links use client form URLs. var options = ValidOptions(); options.Application.EmailConfirmationMode = EmailConfirmationMode.ServerConfirm; options.Application.ClientUrl = null; var result = Validate(options); - Assert.False(result.Failed); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains(nameof(ApplicationOptions.ClientUrl))); } // --------------------------------------------------------------------------- @@ -206,9 +207,10 @@ public void Validate_Fails_WhenClientUrlSetAndBothFormPathsAreInvalid() } [Fact] - public void Validate_Succeeds_WhenClientUrlNullAndFormPathsAreNotChecked() + public void Validate_Fails_WhenClientUrlNullEvenWithServerConfirmMode() { - // When ClientUrl is null / not set, form paths are irrelevant and should not be checked. + // ClientUrl is always required. When it is null, the validation should fail + // for ClientUrl itself, but form paths are not checked since ClientUrl is not set. var options = ValidOptions(); options.Application.EmailConfirmationMode = EmailConfirmationMode.ServerConfirm; options.Application.ClientUrl = null; @@ -217,7 +219,8 @@ public void Validate_Succeeds_WhenClientUrlNullAndFormPathsAreNotChecked() var result = Validate(options); - Assert.False(result.Failed); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains(nameof(ApplicationOptions.ClientUrl))); } // --------------------------------------------------------------------------- @@ -343,8 +346,7 @@ public void Validate_ReportsAllFailures_WhenMultipleRulesAreViolated() { ApiPrefix = null!, EmailConfirmationMode = EmailConfirmationMode.ClientForm, - ClientUrl = null // violates rule 2 - // form paths are null but not checked because ClientUrl is null + ClientUrl = null // violates rule 2 (always required) }, MultiTenant = new MultiTenantOptions { diff --git a/src/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs b/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs rename to tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs similarity index 95% rename from src/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs index 1f1337c..8685c7c 100644 --- a/src/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs @@ -13,6 +13,7 @@ namespace Idmt.UnitTests.Features.Admin; public class RevokeTenantAccessHandlerTests : IDisposable { private readonly Mock _tenantOpsMock; + private readonly Mock _tokenRevocationServiceMock; private readonly IdmtDbContext _dbContext; private readonly Mock> _tenantStoreMock; private readonly RevokeTenantAccess.RevokeTenantAccessHandler _handler; @@ -20,6 +21,7 @@ public class RevokeTenantAccessHandlerTests : IDisposable public RevokeTenantAccessHandlerTests() { _tenantOpsMock = new Mock(); + _tokenRevocationServiceMock = new Mock(); // InMemory DbContext var tenantAccessorMock = new Mock(); @@ -45,6 +47,7 @@ public RevokeTenantAccessHandlerTests() _dbContext, _tenantStoreMock.Object, _tenantOpsMock.Object, + _tokenRevocationServiceMock.Object, NullLogger.Instance); } diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs similarity index 77% rename from src/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs index 7737dad..9669e57 100644 --- a/src/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs @@ -19,6 +19,7 @@ public class LogoutHandlerTests private readonly Mock> _signInManagerMock; private readonly Mock _currentUserServiceMock; private readonly Mock> _tenantContextAccessorMock; + private readonly Mock> _tenantStoreMock; private readonly IOptions _idmtOptions; private readonly Mock _tokenRevocationServiceMock; private readonly Logout.LogoutHandler _handler; @@ -38,6 +39,7 @@ public LogoutHandlerTests() _loggerMock = new Mock>(); _currentUserServiceMock = new Mock(); _tenantContextAccessorMock = new Mock>(); + _tenantStoreMock = new Mock>(); _tokenRevocationServiceMock = new Mock(); // Default: no tenant context resolved. Tests that need a resolved tenant override this. @@ -61,6 +63,7 @@ public LogoutHandlerTests() _signInManagerMock.Object, _currentUserServiceMock.Object, _tenantContextAccessorMock.Object, + _tenantStoreMock.Object, _idmtOptions, _tokenRevocationServiceMock.Object); } @@ -155,18 +158,52 @@ public async Task Logout_SkipsRevocation_WhenUserIdIsNull() } [Fact] - public async Task Logout_SkipsRevocationAndLogsWarning_WhenTenantContextIsNull() + public async Task Logout_RevokesViaFallback_WhenTenantContextIsNullButClaimResolvesToTenant() { - // Arrange: the multi-tenant strategy produced no context (e.g. header or route strategy - // does not fire during logout). The accessor default in the constructor returns null. - // The principal has a tenant claim that the warning should surface in its message. + // Arrange: the multi-tenant strategy produced no context, but the bearer principal + // carries a tenant claim. The fallback resolves the tenant from the store and revokes. var userId = Guid.NewGuid(); var tenantIdentifierFromClaim = "acme-corp"; + var tenantDbId = "acme-db-id"; var principal = BuildPrincipalWithTenantClaim(TenantClaimKey, tenantIdentifierFromClaim); _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); _currentUserServiceMock.SetupGet(c => c.User).Returns(principal); + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierFromClaim)) + .ReturnsAsync(new IdmtTenantInfo(tenantDbId, tenantIdentifierFromClaim, "Acme Corp")); + + _signInManagerMock + .Setup(s => s.SignOutAsync()) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(); + + // Assert: revocation succeeds via fallback + Assert.False(result.IsError); + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(userId, tenantDbId, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Logout_LogsWarning_WhenTenantContextIsNullAndClaimCannotBeResolved() + { + // Arrange: no tenant context and the claim identifier cannot be found in the store. + var userId = Guid.NewGuid(); + var tenantIdentifierFromClaim = "unknown-tenant"; + var principal = BuildPrincipalWithTenantClaim(TenantClaimKey, tenantIdentifierFromClaim); + + _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); + _currentUserServiceMock.SetupGet(c => c.User).Returns(principal); + + // Store returns null — tenant not found + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierFromClaim)) + .ReturnsAsync((IdmtTenantInfo?)null); + _signInManagerMock .Setup(s => s.SignOutAsync()) .Returns(Task.CompletedTask); @@ -174,14 +211,11 @@ public async Task Logout_SkipsRevocationAndLogsWarning_WhenTenantContextIsNull() // Act var result = await _handler.HandleAsync(); - // Assert: sign-out still returns 204 — the user is signed out even without revocation + // Assert: sign-out succeeds but revocation skipped Assert.False(result.IsError); _tokenRevocationServiceMock.Verify( x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - - // Assert: warning is logged and identifies the tenant from bearer claims so operators - // can diagnose the misconfigured strategy VerifyLogWarningContains(tenantIdentifierFromClaim); } @@ -209,16 +243,18 @@ public async Task Logout_LogsWarning_WithNotPresentPlaceholder_WhenBothTenantCon x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - VerifyLogWarningContains(""); + VerifyLogWarningContains("no tenant context resolved"); } [Fact] - public async Task Logout_LogsWarning_WhenTenantContextExistsButTenantInfoIsNull() + public async Task Logout_UsesClaimFallback_WhenTenantContextExistsButTenantInfoIsNull() { // Arrange: Finbuckle returned a context object (resolution ran) but found no matching // tenant store entry — TenantInfo is null, so Id cannot be resolved. + // The fallback reads the claim and resolves via the store. var userId = Guid.NewGuid(); - var tenantIdentifierFromClaim = "unknown-tenant"; + var tenantIdentifierFromClaim = "resolved-tenant"; + var tenantDbId = "resolved-db-id"; var principal = BuildPrincipalWithTenantClaim(TenantClaimKey, tenantIdentifierFromClaim); _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); @@ -230,6 +266,10 @@ public async Task Logout_LogsWarning_WhenTenantContextExistsButTenantInfoIsNull( .SetupGet(a => a.MultiTenantContext) .Returns(contextWithNullTenantInfo.Object); + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierFromClaim)) + .ReturnsAsync(new IdmtTenantInfo(tenantDbId, tenantIdentifierFromClaim, "Resolved Tenant")); + _signInManagerMock .Setup(s => s.SignOutAsync()) .Returns(Task.CompletedTask); @@ -237,22 +277,22 @@ public async Task Logout_LogsWarning_WhenTenantContextExistsButTenantInfoIsNull( // Act var result = await _handler.HandleAsync(); - // Assert + // Assert: revocation proceeds via fallback Assert.False(result.IsError); _tokenRevocationServiceMock.Verify( - x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - - VerifyLogWarningContains(tenantIdentifierFromClaim); + x => x.RevokeUserTokensAsync(userId, tenantDbId, It.IsAny()), + Times.Once); } [Fact] - public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForWarning() + public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForFallback() { // Arrange: a non-default claim key is configured; the handler must read the tenant - // identifier from the correct claim type when composing the warning message. + // identifier from the correct claim type for the fallback resolution. const string customClaimKey = "custom_tenant_claim"; const string tenantIdentifierValue = "my-org"; + const string tenantDbId = "my-org-db-id"; + var userId = Guid.NewGuid(); var options = Options.Create(new IdmtOptions { @@ -266,10 +306,13 @@ public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForWa }); var principal = BuildPrincipalWithTenantClaim(customClaimKey, tenantIdentifierValue); - _currentUserServiceMock.SetupGet(c => c.UserId).Returns(Guid.NewGuid()); + _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); _currentUserServiceMock.SetupGet(c => c.User).Returns(principal); - // Tenant context remains null (default) + // Tenant context remains null (default) — triggers fallback + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierValue)) + .ReturnsAsync(new IdmtTenantInfo(tenantDbId, tenantIdentifierValue, "My Org")); _signInManagerMock .Setup(s => s.SignOutAsync()) @@ -280,14 +323,17 @@ public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForWa _signInManagerMock.Object, _currentUserServiceMock.Object, _tenantContextAccessorMock.Object, + _tenantStoreMock.Object, options, _tokenRevocationServiceMock.Object); // Act await handlerWithCustomOptions.HandleAsync(); - // Assert: warning contains the value from the custom claim key - VerifyLogWarningContains(tenantIdentifierValue); + // Assert: revocation called with correct tenant from custom claim key + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(userId, tenantDbId, It.IsAny()), + Times.Once); } #region Helpers diff --git a/src/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs b/tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs rename to tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs similarity index 97% rename from src/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs index 7cbce7d..060d2ee 100644 --- a/src/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs @@ -18,6 +18,8 @@ public class UpdateUserInfoHandlerTests : IDisposable private readonly Mock> _userManagerMock; private readonly Mock _linkGeneratorMock; private readonly Mock> _emailSenderMock; + private readonly Mock _handlerCurrentUserServiceMock; + private readonly Mock _tokenRevocationServiceMock; private readonly IdmtDbContext _dbContext; private readonly UpdateUserInfo.UpdateUserInfoHandler _handler; @@ -29,6 +31,8 @@ public UpdateUserInfoHandlerTests() _linkGeneratorMock = new Mock(); _emailSenderMock = new Mock>(); + _handlerCurrentUserServiceMock = new Mock(); + _tokenRevocationServiceMock = new Mock(); var tenantAccessorMock = new Mock(); var dummyTenant = new IdmtTenantInfo("system-test-tenant", "system-test", "System Test Tenant"); @@ -54,6 +58,8 @@ public UpdateUserInfoHandlerTests() _dbContext, _linkGeneratorMock.Object, _emailSenderMock.Object, + _handlerCurrentUserServiceMock.Object, + _tokenRevocationServiceMock.Object, NullLogger.Instance); } diff --git a/src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj b/tests/Idmt.UnitTests/Idmt.UnitTests.csproj similarity index 100% rename from src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj rename to tests/Idmt.UnitTests/Idmt.UnitTests.csproj diff --git a/src/tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs b/tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs rename to tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs diff --git a/src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs b/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs rename to tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs diff --git a/src/tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs b/tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs rename to tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs b/tests/Idmt.UnitTests/Services/CoreServicesTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs rename to tests/Idmt.UnitTests/Services/CoreServicesTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs b/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs rename to tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs b/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs rename to tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs b/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs rename to tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs b/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs rename to tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs b/tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs rename to tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs b/tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs rename to tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs rename to tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs diff --git a/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs b/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs rename to tests/Idmt.UnitTests/Validation/ValidatorsTests.cs From cc4ab618aa8f847da7231b6ff6ee68ad28db1676 Mon Sep 17 00:00:00 2001 From: idotta Date: Sun, 15 Mar 2026 01:25:06 -0300 Subject: [PATCH 02/19] Update rate limiting options to be disabled by default and adjust documentation accordingly --- Idmt.Plugin/Configuration/IdmtOptions.cs | 5 +++-- README.md | 10 +++++----- tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs | 1 - .../Configuration/RateLimitingOptionsTests.cs | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Idmt.Plugin/Configuration/IdmtOptions.cs b/Idmt.Plugin/Configuration/IdmtOptions.cs index 58771c6..49e4878 100644 --- a/Idmt.Plugin/Configuration/IdmtOptions.cs +++ b/Idmt.Plugin/Configuration/IdmtOptions.cs @@ -304,9 +304,10 @@ public class DatabaseOptions public class RateLimitingOptions { /// - /// Enable built-in rate limiting for auth endpoints. Default: true. + /// Enable built-in rate limiting for auth endpoints. Default: false. + /// Opt-in for production deployments to protect against brute-force and email-flooding attacks. /// - public bool Enabled { get; set; } = true; + public bool Enabled { get; set; } = false; /// /// Maximum number of requests allowed per window for auth endpoints. Default: 10. diff --git a/README.md b/README.md index 02f71de..e250cb4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ An opinionated .NET 10 library for self-hosted identity management and multi-ten - Dual authentication: cookie-based and bearer token (opaque), resolved automatically per request - Multi-tenancy via header, claim, route, or base-path strategies (Finbuckle.MultiTenant) - Vertical slice architecture — each endpoint is a self-contained handler -- Per-IP fixed-window rate limiting on all auth endpoints +- Optional per-IP fixed-window rate limiting on all auth endpoints (opt-in) - Token revocation on logout with background cleanup - Account lockout (5 failed attempts / 5-minute window) - PII masking in all structured log output @@ -97,7 +97,7 @@ app.Run(); "DatabaseInitialization": "Migrate" }, "RateLimiting": { - "Enabled": true, + "Enabled": false, "PermitLimit": 10, "WindowInSeconds": 60 } @@ -111,7 +111,7 @@ app.Run(); - `EmailConfirmationMode` — `ServerConfirm` sends a GET link that confirms directly on the server; `ClientForm` sends a link to `ClientUrl/ConfirmEmailFormPath` for SPA handling (default). - `DatabaseInitialization` — `Migrate` runs pending EF Core migrations (production default); `EnsureCreated` skips migrations (development/testing); `None` leaves schema management to the consumer. - `Strategies` — ordered list of tenant resolution strategies. Valid values: `header`, `claim`, `route`, `basepath`. -- `RateLimiting` — per-IP fixed-window limiter applied to all `/auth` endpoints. Set `Enabled: false` to opt out. +- `RateLimiting` — per-IP fixed-window limiter applied to all `/auth` endpoints. Disabled by default; set `Enabled: true` in production to protect against brute-force and email-flooding attacks. --- @@ -121,7 +121,7 @@ All endpoints are mounted under `ApiPrefix` (default `/api/v1`). ### Authentication — `/auth` -Rate-limited. All endpoints are public except `/auth/logout`. +Rate-limited when enabled. All endpoints are public except `/auth/logout`. | Method | Path | Auth Required | Description | |--------|------|:---:|-------------| @@ -212,7 +212,7 @@ When using bearer tokens, a middleware (`ValidateBearerTokenTenantMiddleware`) v ## Security -- Per-IP fixed-window rate limiting on all `/auth` endpoints (configurable via `RateLimiting`) +- Optional per-IP fixed-window rate limiting on all `/auth` endpoints (disabled by default; enable via `RateLimiting.Enabled`) - `SameSite=Strict` cookies by default — browser never sends the auth cookie on cross-site requests; `SameSiteMode.None` is blocked and falls back to `Strict` - Security headers on every response: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` - Token revocation on logout stored in the database; a background `IHostedService` periodically purges expired revoked tokens diff --git a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index 4c60066..eb1740c 100644 --- a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs +++ b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs @@ -50,7 +50,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // migrations, and the test factory's SeedAsync initialises the schema directly // via EnsureCreatedAsync before the seeding scope runs. { "Idmt:Database:DatabaseInitialization", "EnsureCreated" }, - { "Idmt:RateLimiting:Enabled", "false" }, }; // Add strategies as indexed array for proper deserialization for (int i = 0; i < _strategies.Length; i++) diff --git a/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs b/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs index 72fc0b8..5e92cf6 100644 --- a/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs +++ b/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs @@ -10,10 +10,10 @@ public class RateLimitingOptionsTests // ------------------------------------------------------------------ [Fact] - public void Enabled_DefaultsToTrue() + public void Enabled_DefaultsToFalse() { var options = new RateLimitingOptions(); - Assert.True(options.Enabled); + Assert.False(options.Enabled); } [Fact] @@ -40,18 +40,18 @@ public void IdmtOptions_ExposesRateLimitingProperty_WithDefaults() var idmtOptions = new IdmtOptions(); Assert.NotNull(idmtOptions.RateLimiting); - Assert.True(idmtOptions.RateLimiting.Enabled); + Assert.False(idmtOptions.RateLimiting.Enabled); Assert.Equal(10, idmtOptions.RateLimiting.PermitLimit); Assert.Equal(60, idmtOptions.RateLimiting.WindowInSeconds); } [Fact] - public void IdmtOptions_Default_HasRateLimitingEnabled() + public void IdmtOptions_Default_HasRateLimitingDisabled() { var defaults = IdmtOptions.Default; Assert.NotNull(defaults.RateLimiting); - Assert.True(defaults.RateLimiting.Enabled); + Assert.False(defaults.RateLimiting.Enabled); } // ------------------------------------------------------------------ From 2f8ef846815cc8acf53bd3c9a863b822edf24877 Mon Sep 17 00:00:00 2001 From: idotta Date: Tue, 28 Apr 2026 21:12:18 -0300 Subject: [PATCH 03/19] fix(admin): require sysadmin + restore tenant context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TenantOperationService.ExecuteInTenantScopeAsync now captures and restores the ambient AsyncLocal tenant context in a try/finally. Without restore, nested delegates and throws leaked the inner tenant into the outer request scope — latent cross-tenant write corruption. - Admin mutations (Create/Delete tenant, Grant/Revoke tenant access) promoted from RequireSysUser to RequireSysAdmin. SysSupport could previously create tenants and grant itself access to any tenant, escalating to arbitrary tenant admin. - Listings (GetAllTenants, GetUserTenants) stay on RequireSysUser — SysSupport keeps legitimate read access. - Grant/Revoke reject self-target with General.SelfTarget; guard runs before any DB lookup to avoid timing oracle. --- Idmt.Plugin/Errors/IdmtErrors.cs | 4 + Idmt.Plugin/Features/Admin/CreateTenant.cs | 1 + Idmt.Plugin/Features/Admin/DeleteTenant.cs | 2 +- .../Features/Admin/GrantTenantAccess.cs | 13 +- .../Features/Admin/RevokeTenantAccess.cs | 16 +- Idmt.Plugin/Features/AdminEndpoints.cs | 4 +- .../Services/ITenantOperationService.cs | 13 ++ .../Services/TenantOperationService.cs | 25 ++- .../AdminIntegrationTests.cs | 177 ++++++++++++++++++ .../Admin/GrantTenantAccessHandlerTests.cs | 4 + .../Admin/RevokeTenantAccessHandlerTests.cs | 2 + .../Services/TenantOperationServiceTests.cs | 128 +++++++++++-- 12 files changed, 362 insertions(+), 27 deletions(-) diff --git a/Idmt.Plugin/Errors/IdmtErrors.cs b/Idmt.Plugin/Errors/IdmtErrors.cs index 48d667a..028a82f 100644 --- a/Idmt.Plugin/Errors/IdmtErrors.cs +++ b/Idmt.Plugin/Errors/IdmtErrors.cs @@ -149,5 +149,9 @@ public static class General public static Error Unexpected => Error.Unexpected( code: "General.Unexpected", description: "An unexpected error occurred"); + + public static Error SelfTarget => Error.Validation( + code: "General.SelfTarget", + description: "Operation cannot target the current user"); } } diff --git a/Idmt.Plugin/Features/Admin/CreateTenant.cs b/Idmt.Plugin/Features/Admin/CreateTenant.cs index 60f34c7..5f1eec9 100644 --- a/Idmt.Plugin/Features/Admin/CreateTenant.cs +++ b/Idmt.Plugin/Features/Admin/CreateTenant.cs @@ -154,6 +154,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..dfafbed 100644 --- a/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -35,12 +35,23 @@ internal sealed class GrantTenantAccessHandler( 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.General.Unexpected; + } + + 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"); @@ -236,7 +247,7 @@ public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRou } 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..5f2d814 100644 --- a/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -32,10 +32,21 @@ internal sealed class RevokeTenantAccessHandler( IMultiTenantStore tenantStore, ITenantOperationService tenantOps, ITokenRevocationService tokenRevocationService, + ICurrentUserService currentUserService, ILogger logger) : IRevokeTenantAccessHandler { public async Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default) { + if (currentUserService.UserId is null) + { + return IdmtErrors.General.Unexpected; + } + + if (userId == currentUserService.UserId.Value) + { + return IdmtErrors.General.SelfTarget; + } + IdmtUser? user; try @@ -96,7 +107,7 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti 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 +118,14 @@ public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRo { return result.FirstError.Type switch { + ErrorType.Validation => TypedResults.BadRequest(), ErrorType.NotFound => TypedResults.NotFound(), _ => 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/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/TenantOperationService.cs b/Idmt.Plugin/Services/TenantOperationService.cs index 917ae80..992a6b1 100644 --- a/Idmt.Plugin/Services/TenantOperationService.cs +++ b/Idmt.Plugin/Services/TenantOperationService.cs @@ -13,11 +13,18 @@ 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; + 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 +36,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 +52,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/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 815bb6d..37f72d8 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; @@ -558,6 +559,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_400_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.BadRequest, response.StatusCode); + } + + [Fact] + public async Task SysAdmin_revoke_access_from_self_returns_400_with_SelfTarget() + { + var sysClient = await CreateAuthenticatedClientAsync(); + var sysAdminId = await GetSysAdminUserIdAsync(); + var response = await sysClient.DeleteAsync( + $"/admin/users/{sysAdminId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + Assert.Equal(HttpStatusCode.BadRequest, 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.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs index 33dc9f7..81763b8 100644 --- a/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs @@ -30,6 +30,7 @@ public GrantTenantAccessHandlerTests() // Set up InMemory DbContext 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); @@ -57,6 +58,7 @@ public GrantTenantAccessHandlerTests() _userManagerMock.Object, _tenantStoreMock.Object, _tenantOpsMock.Object, + currentUserServiceMock.Object, _timeProvider, NullLogger.Instance); } @@ -229,6 +231,7 @@ public async Task ReturnsAccessError_AndExecutesCompensatingAction_WhenSaveChang 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); @@ -297,6 +300,7 @@ public async Task ReturnsAccessError_AndExecutesCompensatingAction_WhenSaveChang userManagerMock.Object, tenantStoreMock.Object, tenantOpsMock.Object, + currentUserServiceMock.Object, _timeProvider, NullLogger.Instance); diff --git a/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs index 8685c7c..6bbb339 100644 --- a/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs @@ -26,6 +26,7 @@ public RevokeTenantAccessHandlerTests() // InMemory DbContext 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); @@ -48,6 +49,7 @@ public RevokeTenantAccessHandlerTests() _tenantStoreMock.Object, _tenantOpsMock.Object, _tokenRevocationServiceMock.Object, + currentUserServiceMock.Object, NullLogger.Instance); } 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); } } From 59d31f0f48bbfe9b0421df1913b7a6c77a7877c4 Mon Sep 17 00:00:00 2001 From: idotta Date: Tue, 28 Apr 2026 21:33:40 -0300 Subject: [PATCH 04/19] =?UTF-8?q?fix(admin):=20self-target=20=E2=86=92=204?= =?UTF-8?q?03;=20add=20401/403=20arms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - General.SelfTarget reclassified Validation → Forbidden. Self-target is policy denial, not a malformed request; matches repo convention (Auth.Forbidden, Tenant.Inactive, CannotDeleteDefault, User.Inactive). - Grant/Revoke endpoint switches now map ErrorType.Forbidden to Forbid() and ErrorType.Unauthorized to Unauthorized(); Results<...> unions extended with ForbidHttpResult and UnauthorizedHttpResult. - Null-UserId fail-closed branch in both handlers now returns Auth.Unauthorized (401) instead of General.Unexpected (500). Branch unreachable behind RequireSysAdminPolicy; honest status for defense in depth. - Self-target integration tests updated to assert 403. BREAKING CHANGE: POST and DELETE on /admin/users/{userId}/tenants/{tenantIdentifier} now return 403 instead of 400 when userId == caller.UserId. OpenAPI spec adds 401 and 403 response variants on both routes. --- Idmt.Plugin/Errors/IdmtErrors.cs | 4 ++-- Idmt.Plugin/Features/Admin/GrantTenantAccess.cs | 6 ++++-- Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs | 6 ++++-- tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs | 8 ++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Idmt.Plugin/Errors/IdmtErrors.cs b/Idmt.Plugin/Errors/IdmtErrors.cs index 028a82f..05f84b2 100644 --- a/Idmt.Plugin/Errors/IdmtErrors.cs +++ b/Idmt.Plugin/Errors/IdmtErrors.cs @@ -150,8 +150,8 @@ public static class General code: "General.Unexpected", description: "An unexpected error occurred"); - public static Error SelfTarget => Error.Validation( + public static Error SelfTarget => Error.Forbidden( code: "General.SelfTarget", - description: "Operation cannot target the current user"); + description: "This operation cannot target the caller"); } } diff --git a/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index dfafbed..b6c631d 100644 --- a/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -44,7 +44,7 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti { if (currentUserService.UserId is null) { - return IdmtErrors.General.Unexpected; + return IdmtErrors.Auth.Unauthorized; } if (userId == currentUserService.UserId.Value) @@ -228,7 +228,7 @@ await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async tsp => 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, @@ -242,6 +242,8 @@ public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRou { ErrorType.Validation => TypedResults.BadRequest(), ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Unauthorized => TypedResults.Unauthorized(), _ => TypedResults.InternalServerError(), }; } diff --git a/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index 5f2d814..256d2c4 100644 --- a/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -39,7 +39,7 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti { if (currentUserService.UserId is null) { - return IdmtErrors.General.Unexpected; + return IdmtErrors.Auth.Unauthorized; } if (userId == currentUserService.UserId.Value) @@ -107,7 +107,7 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti 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, @@ -120,6 +120,8 @@ public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRo { ErrorType.Validation => TypedResults.BadRequest(), ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Unauthorized => TypedResults.Unauthorized(), _ => TypedResults.InternalServerError(), }; } diff --git a/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 37f72d8..7c6bf02 100644 --- a/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -681,24 +681,24 @@ public async Task SysSupport_can_get_user_tenants_returns_200() } [Fact] - public async Task SysAdmin_grant_access_to_self_returns_400_with_SelfTarget() + 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.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] - public async Task SysAdmin_revoke_access_from_self_returns_400_with_SelfTarget() + 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.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] From b9af1bfa5ea828cadc0c16dcfb2b11eb0e7a7c42 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 09:40:10 -0300 Subject: [PATCH 05/19] feat(identity): add SysRoleKind enum and PendingEmail to IdmtUser Phase 1 step 1: pure-additive scaffolding for canonical identity migration. SysRoleKind values map 1:1 to IdmtDefaultRoleTypes string constants so existing RequireRole("SysAdmin"/"SysSupport") policies keep matching after the SysRole column replaces per-tenant role rows. PendingEmail column reserved for OOB email-change flow (later step). No removals; IdmtUser.TenantId stays until step 2. Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- Idmt.Plugin/Models/IdmtUser.cs | 10 +++++ Idmt.Plugin/Models/SysRoleKind.cs | 8 ++++ tests/Idmt.UnitTests/Models/IdmtUserTests.cs | 20 ++++++++++ .../Idmt.UnitTests/Models/SysRoleKindTests.cs | 37 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 Idmt.Plugin/Models/SysRoleKind.cs create mode 100644 tests/Idmt.UnitTests/Models/IdmtUserTests.cs create mode 100644 tests/Idmt.UnitTests/Models/SysRoleKindTests.cs diff --git a/Idmt.Plugin/Models/IdmtUser.cs b/Idmt.Plugin/Models/IdmtUser.cs index 3ae6811..b0ad799 100644 --- a/Idmt.Plugin/Models/IdmtUser.cs +++ b/Idmt.Plugin/Models/IdmtUser.cs @@ -19,6 +19,16 @@ public class IdmtUser : IdentityUser, IAuditable /// public string TenantId { get; set; } = null!; + /// + /// System-level role assignment for this user. Defaults to . + /// + 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. /// 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/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()); + } +} From b92a73f78eb2d2e3f93d2a103ac65f25f666e694 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 10:04:19 -0300 Subject: [PATCH 06/19] feat(identity)!: make IdmtUser global; drop TenantId column BREAKING CHANGE: IdmtUser is no longer per-tenant. The TenantId column, the IsMultiTenant() filter, and the legacy (Email, UserName, TenantId) unique index are removed. Consumers must add an EF migration that drops the column + index and adds a global unique index on NormalizedEmail. IdmtUserClaimsPrincipalFactory ctor now takes IMultiTenantContextAccessor instead of IMultiTenantStore; tenant claim is sourced from the ambient context and the factory throws InvalidOperationException when the ambient tenant is null (fail-closed, per audit CD-4). - IdmtUser.GetTenantId() returns null so audit rows for global entity writes carry no tenant. IdmtAuditLog.TenantId is already nullable. - SysRole projected as Claim(ClaimTypes.Role, SysRole.ToString()) when != None so existing RequireRole("SysAdmin"/"SysSupport") policies continue to match without code change. - DiscoverTenants no longer reads Users.TenantId (gone); TenantAccess is the sole source of cross-tenant membership. - 6 integration tests fail by design and are bounded by the plan: 3 cross-tenant logins succeed because the TenantAccess gate is not yet wired at the login path (deferred to a later step that wires the uniform gate); 3 GET /manage/info return 400 because seeded SysAdmin has no per-tenant IdentityRole (deferred to the role-seeding shrink). Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- Idmt.Plugin/Features/Auth/DiscoverTenants.cs | 25 +- Idmt.Plugin/Features/Manage/GetUserInfo.cs | 10 +- Idmt.Plugin/Features/Manage/RegisterUser.cs | 1 - Idmt.Plugin/Models/IdmtUser.cs | 11 +- Idmt.Plugin/Persistence/IdmtDbContext.cs | 43 ++- .../IdmtUserClaimsPrincipalFactory.cs | 38 ++- .../Services/TenantOperationService.cs | 1 + samples/Idmt.BasicSample/SeedTestUser.cs | 4 +- .../Idmt.BasicSample.Tests/IdmtApiFactory.cs | 18 +- .../MultiTenancyIntegrationTests.cs | 20 +- .../Admin/GrantTenantAccessHandlerTests.cs | 8 +- .../Admin/RevokeTenantAccessHandlerTests.cs | 4 +- .../Features/Auth/ConfirmEmailHandlerTests.cs | 2 +- .../Auth/DiscoverTenantsHandlerTests.cs | 87 +++--- .../Auth/ForgotPasswordHandlerTests.cs | 4 +- .../Features/Auth/LoginHandlerTests.cs | 1 - .../Features/Auth/RefreshTokenHandlerTests.cs | 10 +- .../ResendConfirmationEmailHandlerTests.cs | 2 +- .../Auth/ResetPasswordHandlerTests.cs | 6 +- .../Features/Auth/TokenLoginHandlerTests.cs | 1 - .../Manage/GetUserInfoHandlerTests.cs | 59 ++-- .../Features/Manage/UnregisterHandlerTests.cs | 4 +- .../Features/Manage/UpdateUserHandlerTests.cs | 4 +- .../Manage/UpdateUserInfoHandlerTests.cs | 16 +- .../Persistence/IdmtDbContextTests.cs | 97 +++++++ .../IdmtUserClaimsPrincipalFactoryTests.cs | 269 ++++++++---------- 26 files changed, 431 insertions(+), 314 deletions(-) create mode 100644 tests/Idmt.UnitTests/Persistence/IdmtDbContextTests.cs 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/Manage/GetUserInfo.cs b/Idmt.Plugin/Features/Manage/GetUserInfo.cs index 927a241..9180c19 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) { @@ -47,7 +49,9 @@ public async Task> HandleAsync(ClaimsPrincipal user var roles = (await userManager.GetRolesAsync(appUser)).OrderBy(r => r).ToList(); if (roles.Count == 0) return IdmtErrors.User.NoRolesAssigned; - 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 +60,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..64707b4 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, }; diff --git a/Idmt.Plugin/Models/IdmtUser.cs b/Idmt.Plugin/Models/IdmtUser.cs index b0ad799..fafc6ef 100644 --- a/Idmt.Plugin/Models/IdmtUser.cs +++ b/Idmt.Plugin/Models/IdmtUser.cs @@ -14,11 +14,6 @@ public class IdmtUser : IdentityUser, IAuditable public override string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); - /// - /// The tenant this user belongs to. - /// - public string TenantId { get; set; } = null!; - /// /// System-level role assignment for this user. Defaults to . /// @@ -43,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/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/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 992a6b1..c7cdb25 100644 --- a/Idmt.Plugin/Services/TenantOperationService.cs +++ b/Idmt.Plugin/Services/TenantOperationService.cs @@ -20,6 +20,7 @@ public async Task> ExecuteInTenantScopeAsync( 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; 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/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index eb1740c..4cf2fd8 100644 --- a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs +++ b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs @@ -184,10 +184,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 +198,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 +210,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/MultiTenancyIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index 2a00b28..5d1eb28 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; @@ -44,9 +46,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 diff --git a/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs index 81763b8..402b0a5 100644 --- a/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs @@ -117,7 +117,7 @@ public async Task ReturnsTenantInactive_WhenTargetTenantIsInactive() Id = userId, UserName = "testuser", Email = "test@test.com", - TenantId = "sys-id" + }); await _dbContext.SaveChangesAsync(); @@ -144,7 +144,7 @@ public async Task ReturnsNoRolesAssigned_WhenUserHasNoRoles() Id = userId, UserName = "noroles", Email = "noroles@test.com", - TenantId = "sys-id" + }); await _dbContext.SaveChangesAsync(); @@ -177,7 +177,7 @@ public async Task ReactivatesExistingAccess_WhenRecordAlreadyExists() Id = userId, UserName = "existinguser", Email = "existing@test.com", - TenantId = "sys-id" + }); // Pre-existing inactive access record @@ -253,7 +253,7 @@ public async Task ReturnsAccessError_AndExecutesCompensatingAction_WhenSaveChang Id = userId, UserName = "compuser", Email = "comp@test.com", - TenantId = "sys-id" + }); await seedContext.SaveChangesAsync(); } diff --git a/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs index 6bbb339..9766279 100644 --- a/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs @@ -64,7 +64,7 @@ public async Task ReturnsAccessNotFound_WhenNoAccessRecord() Id = userId, UserName = "testuser", Email = "test@test.com", - TenantId = "sys-id" + }); await _dbContext.SaveChangesAsync(); @@ -95,7 +95,7 @@ public async Task SucceedsGracefully_WhenUserNotInTenantScope() Id = userId, UserName = "scopeuser", Email = "scope@test.com", - TenantId = "sys-id" + }); _dbContext.TenantAccess.Add(new TenantAccess diff --git a/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs index 8b1020b..036c9d7 100644 --- a/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs @@ -49,7 +49,7 @@ public async Task ReturnsConfirmationFailed_WhenUserNotFound() public async Task ReturnsConfirmationFailed_WhenTokenIsInvalid() { // Arrange - var user = new IdmtUser { UserName = "test", Email = "test@test.com", TenantId = "t1" }; + var user = new IdmtUser { UserName = "test", Email = "test@test.com" }; var userManagerMock = new Mock>( new Mock>().Object, null!, null!, null!, null!, null!, null!, null!, null!); 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..a99492b 100644 --- a/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs @@ -94,7 +94,6 @@ private static IdmtUser CreateActiveUser() => Id = Guid.NewGuid(), Email = "test@example.com", UserName = "testuser", - TenantId = "tenant-id", IsActive = true }; 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..ef3e720 100644 --- a/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs @@ -31,7 +31,7 @@ public async Task ReturnsResetFailed_WhenUserIsInactive() UserName = "inactive", Email = "inactive@test.com", IsActive = false, - TenantId = "t1" + }; var userManagerMock = new Mock>( @@ -62,7 +62,7 @@ public async Task ReturnsResetFailed_WhenIdentityResetFails() UserName = "testuser", Email = "test@test.com", IsActive = true, - TenantId = "t1" + }; var userManagerMock = new Mock>( @@ -98,7 +98,7 @@ public async Task SetsEmailConfirmed_WhenUserEmailWasUnconfirmed() Email = "test@test.com", IsActive = true, EmailConfirmed = false, - TenantId = "t1" + }; var userManagerMock = new Mock>( diff --git a/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs index 4bfd6b8..9a63cfb 100644 --- a/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs @@ -79,7 +79,6 @@ private static IdmtUser CreateActiveUser() => Id = Guid.NewGuid(), Email = "test@example.com", UserName = "testuser", - TenantId = "tenant-id", IsActive = true }; diff --git a/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs index 8f241ce..f7e8029 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,26 +154,21 @@ 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); 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..5e8fd15 100644 --- a/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs @@ -91,7 +91,7 @@ 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); @@ -116,7 +116,7 @@ public async Task SkipsUpdate_WhenNoFieldsChanged() { UserName = "currentname", Email = "user@test.com", - TenantId = "tenant-1", + IsActive = true }; _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); @@ -145,7 +145,7 @@ public async Task SendsConfirmationEmail_WhenEmailChanged() { UserName = "testuser", Email = "old@test.com", - TenantId = "tenant-1", + IsActive = true, EmailConfirmed = true }; @@ -196,7 +196,7 @@ public async Task DoesNotCallUpdateAsync_WhenOnlyEmailChanged() { UserName = "testuser", Email = "old@test.com", - TenantId = "tenant-1", + IsActive = true, EmailConfirmed = true }; @@ -236,7 +236,7 @@ public async Task CallsUpdateAsync_WhenUsernameAndEmailBothChanged() { UserName = "oldname", Email = "old@test.com", - TenantId = "tenant-1", + IsActive = true, EmailConfirmed = true }; @@ -280,7 +280,7 @@ public async Task DoesNotChangeEmail_WhenNewEmailSameAsCurrent() { UserName = "testuser", Email = "same@test.com", - TenantId = "tenant-1", + IsActive = true, EmailConfirmed = true }; @@ -308,7 +308,7 @@ public async Task DoesNotChangeUsername_WhenNewUsernameSameAsCurrent() { UserName = "currentname", Email = "user@test.com", - TenantId = "tenant-1", + IsActive = true }; _userManagerMock.Setup(x => x.FindByEmailAsync("user@test.com")).ReturnsAsync(user); @@ -338,7 +338,7 @@ public async Task ReturnsUpdateFailed_WhenChangeEmailFails() { UserName = "testuser", Email = "old@test.com", - TenantId = "tenant-1", + IsActive = true, EmailConfirmed = true }; 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/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); } } - From dd635984ef5c527ae23aa5223fbb1c96f39c5245 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 10:12:21 -0300 Subject: [PATCH 07/19] refactor(admin): rewrite GrantTenantAccess as TenantAccess-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the canonical-identity model the handler no longer materialises a per-tenant shadow IdmtUser; the canonical row already exists and is reachable via UserManager.FindByIdAsync. Collapses three coupled defects into one transaction: - C4: shadow row was created by copying the source PasswordHash verbatim, drifting from the canonical hash on rotation. - N1: token revocation keyed on the shadow row's UserId, so a revoke in tenant A never reached the shadow session in tenant B. - N3: the shadow row was committed by UserManager.CreateAsync inside ExecuteInTenantScopeAsync before the outer TenantAccess save, leaving an unreferenced user if the outer save failed. Handler flow: FindByIdAsync (global) → tenant lookup + active check → upsert TenantAccess (Add or flip IsActive + ExpiresAt) → single SaveChangesAsync. Phase 0 self-grant guard kept verbatim. Drop ITenantOperationService injection; no compensation path remains. Also closes H7: lookups no longer compare raw Email/UserName strings. Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- .../Features/Admin/GrantTenantAccess.cs | 151 +--------- .../GrantTenantAccessIntegrationTests.cs | 174 +++++++++++ .../Admin/GrantTenantAccessHandlerTests.cs | 280 +++++++++--------- 3 files changed, 332 insertions(+), 273 deletions(-) create mode 100644 tests/Idmt.BasicSample.Tests/Admin/GrantTenantAccessIntegrationTests.cs diff --git a/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index b6c631d..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,15 +25,15 @@ 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 @@ -57,19 +56,16 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti 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; @@ -80,149 +76,32 @@ 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; } } 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.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs index 402b0a5..96f1c83 100644 --- a/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs @@ -13,24 +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.SetupGet(x => x.UserId).Returns(Guid.NewGuid()); + _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); @@ -42,7 +47,7 @@ public GrantTenantAccessHandlerTests() _dbContext = new IdmtDbContext( tenantAccessorMock.Object, dbOptions, - currentUserServiceMock.Object, + _currentUserServiceMock.Object, TimeProvider.System, NullLogger.Instance); @@ -52,27 +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, + _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); @@ -81,154 +110,153 @@ 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", + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "u", Email = "u@test.com" }; + StubFindUser(user); + StubTenant_NotFound("nope-tenant"); - }); - await _dbContext.SaveChangesAsync(); + var result = await _handler.HandleAsync(user.Id, "nope-tenant"); - var inactiveTenant = new IdmtTenantInfo("tid", "inactive-tenant", "Inactive") { IsActive = false }; - _tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("inactive-tenant")) - .ReturnsAsync(inactiveTenant); + Assert.True(result.IsError); + Assert.Equal("Tenant.NotFound", result.FirstError.Code); + } - // Act - var result = await _handler.HandleAsync(userId, "inactive-tenant"); + [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", - - }); + 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", - - }); - - // 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()); @@ -241,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", - - }); + 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() @@ -268,56 +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() @@ -328,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 { From 167ebc890966f229c4d74f4b82e695a0d2a0078f Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 10:18:37 -0300 Subject: [PATCH 08/19] refactor(admin): rewrite RevokeTenantAccess by canonical UserId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under shadow-row identity, RevokeUserTokensAsync was keyed on the caller's UserId from the outer scope, which never matched the shadow row's UserId in the target tenant — revocations issued in tenant A silently failed to cut sessions in tenant B (audit N1). Handler flow: FindByIdAsync (global) → tenant lookup → TenantAccess by (UserId, TenantId) → flip IsActive=false → single SaveChangesAsync → RevokeUserTokensAsync only on successful save. No ExecuteInTenantScopeAsync; no shadow-user deactivation; no case-sensitive Email/UserName lookup (closes H7). Token revocation is sequenced after the save so a write failure does not leave a desynchronised "revoked but still active" state. Atomicity test asserts RevokeUserTokensAsync is never called when SaveChangesAsync throws. Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- .../Features/Admin/RevokeTenantAccess.cs | 47 +-- .../RevokeTenantAccessIntegrationTests.cs | 130 +++++++ .../Admin/RevokeTenantAccessHandlerTests.cs | 319 ++++++++++++++---- 3 files changed, 405 insertions(+), 91 deletions(-) create mode 100644 tests/Idmt.BasicSample.Tests/Admin/RevokeTenantAccessIntegrationTests.cs diff --git a/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index 256d2c4..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,14 +22,15 @@ 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 @@ -47,11 +47,10 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti return IdmtErrors.General.SelfTarget; } - IdmtUser? user; - 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; @@ -64,44 +63,26 @@ 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); } } 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.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs index 9766279..2a311f9 100644 --- a/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs @@ -4,29 +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.SetupGet(x => x.UserId).Returns(Guid.NewGuid()); + _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); @@ -38,104 +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, + _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", + private void StubFindUser_NotFound(Guid userId) + { + _userManagerMock + .Setup(x => x.FindByIdAsync(userId.ToString())) + .ReturnsAsync((IdmtUser?)null); + } - }); - await _dbContext.SaveChangesAsync(); + private void StubTenant(string identifier, string id) + { + var t = new IdmtTenantInfo(id, identifier, identifier) { IsActive = true }; + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(identifier)) + .ReturnsAsync(t); + } - var tenant = new IdmtTenantInfo("tid", "target-tenant", "Target"); + private void StubTenant_NotFound(string identifier) + { _tenantStoreMock - .Setup(x => x.GetByIdentifierAsync("target-tenant")) - .ReturnsAsync(tenant); + .Setup(x => x.GetByIdentifierAsync(identifier)) + .ReturnsAsync((IdmtTenantInfo?)null); + } - // No access record seeded + [Fact] + public async Task Handle_NullCurrentUser_ReturnsUnauthorized() + { + _currentUserServiceMock.SetupGet(x => x.UserId).Returns((Guid?)null); - // Act - var result = await _handler.HandleAsync(userId, "target-tenant"); + var result = await _handler.HandleAsync(Guid.NewGuid(), "some-tenant"); - // Assert Assert.True(result.IsError); - Assert.Equal("Tenant.AccessNotFound", result.FirstError.Code); + Assert.Equal("Auth.Unauthorized", result.FirstError.Code); } [Fact] - public async Task SucceedsGracefully_WhenUserNotInTenantScope() + public async Task Handle_SelfTarget_ReturnsForbidden() { - // Arrange - var userId = Guid.NewGuid(); - var tenantId = "tid"; + var result = await _handler.HandleAsync(_callerUserId, "some-tenant"); - _dbContext.Users.Add(new IdmtUser - { - Id = userId, - UserName = "scopeuser", - Email = "scope@test.com", + 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.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 Handle_ActiveAccess_FlipsIsActiveFalse_AndRevokesTokens() + { + // Arrange + var user = new IdmtUser { Id = Guid.NewGuid(), UserName = "active", Email = "active@test.com" }; + _dbContext.Users.Add(user); _dbContext.TenantAccess.Add(new TenantAccess { - UserId = userId, - TenantId = tenantId, + UserId = user.Id, + TenantId = "tid-active", 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-active"); // Act - var result = await _handler.HandleAsync(userId, "target-tenant"); + var result = await _handler.HandleAsync(user.Id, "target-tenant"); // Assert Assert.False(result.IsError); - // Verify the access record was deactivated - 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-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 = user.Id, + TenantId = "tid-canonical", + IsActive = true + }); + await _dbContext.SaveChangesAsync(); + + StubFindUser(user); + StubTenant("target-tenant", "tid-canonical"); + + 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.False(result.IsError); + Assert.Equal(user.Id, capturedUserId); + Assert.Equal("tid-canonical", capturedTenantId); + } + + [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); + + 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); - Assert.NotNull(access); - Assert.False(access.IsActive); + 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"); + } + } } From 110872b717d55a22b46a4fa5d821b658abb9836f Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 10:24:54 -0300 Subject: [PATCH 09/19] refactor(auth)!: drop TenantIdentifier from ConfirmEmail surface BREAKING CHANGE: ConfirmEmailRequest no longer accepts TenantIdentifier in the POST body and the GET endpoint no longer accepts it as a query parameter. Tenant is resolved from the ambient Finbuckle context (header/route per consumer config). Clients that pass TenantIdentifier in the body will have it silently ignored; clients reading it from the link URL will need to remove it after Step 8 strips it from generated links. ConfirmEmailAsync's DataProtector token is bound to (user.Id, SecurityStamp, "EmailConfirmation") and was already tenant-agnostic. Under the now-global IdmtUser the same token validates regardless of which tenant context the request resolves under, which is the intended canonical behaviour. Handler ctor drops ITenantOperationService and uses UserManager directly; the ExecuteInTenantScopeAsync wrap is gone. This closes the C3 hygiene gap where body-supplied tenant decoupled token handling from the request's tenant strategy and would have become exploitable the moment anyone reintroduced shadow-row Id/SecurityStamp copying. Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- Idmt.Plugin/Features/Auth/ConfirmEmail.cs | 44 ++++----- .../ConfirmEmailRequestValidator.cs | 3 - .../AuthIntegrationTests.cs | 72 +++++++++++--- .../Features/Auth/ConfirmEmailHandlerTests.cs | 99 ++++++++++--------- .../Validation/FluentValidatorTests.cs | 3 +- 5 files changed, 130 insertions(+), 91 deletions(-) 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/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/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs index b1cd248..179b133 100644 --- a/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs @@ -404,37 +404,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 +482,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 +495,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); } diff --git a/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs index 036c9d7..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); @@ -50,24 +70,23 @@ public async Task ReturnsConfirmationFailed_WhenTokenIsInvalid() { // Arrange var user = new IdmtUser { UserName = "test", Email = "test@test.com" }; - - 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(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/Validation/FluentValidatorTests.cs b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs index 03f2b1c..19c9682 100644 --- a/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs +++ b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs @@ -154,9 +154,8 @@ 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); } From 84a0ec6e56aa28bcdbdbde4d5d02d11b1c097f12 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 10:34:04 -0300 Subject: [PATCH 10/19] fix(auth)!: stop ResetPassword from confirming email on success BREAKING CHANGE: ResetPasswordRequest no longer accepts TenantIdentifier in the body, and a successful reset no longer flips EmailConfirmed = true on the user. The implicit side-effect was the second leg of an account-takeover chain (audit C7): an attacker with a temp session stages a new email via PUT /manage/info, then issues forgot-password against the new address they control, and the silent confirmation flip on reset bound the account to the attacker without any out-of-band proof against the victim's mailbox. Reset only proves possession of the current Email; confirmation of a new address belongs to the OOB email-change flow landing in a later step. Handler ctor drops ITenantOperationService and uses UserManager directly; the ExecuteInTenantScopeAsync wrap is gone (tenant resolved from the ambient Finbuckle context). Body-supplied TenantIdentifier is silently ignored. Two regression tests pin the C7 fix from both sides (EmailConfirmed=false stays false; =true stays true) plus a Moq Times.Never assertion that UpdateAsync is not called for the flip. An integration test reads EmailConfirmed back from the real DB. Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- Idmt.Plugin/Features/Auth/ResetPassword.cs | 51 +++--- .../ResetPasswordRequestValidator.cs | 3 - .../AuthIntegrationTests.cs | 101 +++++++++-- .../BaseIntegrationTest.cs | 2 +- .../MultiTenancyIntegrationTests.cs | 2 +- .../Auth/ResetPasswordHandlerTests.cs | 169 ++++++++++++------ .../Validation/FluentValidatorTests.cs | 2 +- 7 files changed, 221 insertions(+), 109 deletions(-) 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/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/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs index 179b133..f9f6fe9 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; @@ -618,7 +619,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(); @@ -654,18 +655,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() { @@ -673,7 +662,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); } @@ -702,7 +691,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(); } @@ -728,7 +717,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(); @@ -749,7 +738,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); } @@ -774,10 +763,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/MultiTenancyIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index 5d1eb28..65a1e0b 100644 --- a/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -350,7 +350,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/Auth/ResetPasswordHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs index ef3e720..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, - }; - 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!"); + + // 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 request = new ResetPassword.ResetPasswordRequest("test-tenant", "inactive@test.com", "token", "NewPass123!"); + 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, - }; - 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, - }; - 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/Validation/FluentValidatorTests.cs b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs index 19c9682..59adad7 100644 --- a/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs +++ b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs @@ -194,7 +194,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); } From c6b10e9599280e08ea0b0b49b370cd2dcc6d1e45 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 10:54:19 -0300 Subject: [PATCH 11/19] feat(auth)!: stage email change for OOB confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: PUT /manage/info no longer mutates Email immediately when NewEmail is set. The new email is staged in IdmtUser.PendingEmail and a confirmation link is sent to that address; Email is committed only when the recipient POSTs to /auth/confirm-email-change with the token. The endpoint returns 202 Accepted (Location: /auth/confirm-email-change) instead of 200 in this case. Existing clients that treated 200 as success must accept 202 and surface the "check your inbox" prompt. Closes the C7 account-takeover chain in tandem with the previous ResetPassword change: an attacker can no longer rebind the Email column to an address they control without out-of-band proof against that mailbox. Invariant: every stamp-rotating Identity mutation in the request (SetUserNameAsync, ChangePasswordAsync) completes before GenerateChangeEmailTokenAsync. The handler flushes via SaveChangesAsync and reloads the user so the token is bound to the persisted post-rotation SecurityStamp; otherwise the token would fail ChangeEmailAsync at confirm time. Tests F25 (password+email) and F44 (username+email) cover both rotation paths end-to-end via real Identity token generation against SQLite. Token revocation is now gated on credential changes only. Email-only staging keeps the bearer session alive so the user can read the confirmation mail in the same browser tab; ChangeEmailAsync rotates the stamp at confirm time and invalidates outstanding sessions then. Other surface: - New POST /auth/confirm-email-change (AllowAnonymous) — pre-checks PendingEmail to avoid wasting Identity token validation on stale state, then ChangeEmailAsync (atomic Email + EmailConfirmed + stamp) and clears PendingEmail. - New IIdmtLinkGenerator.GenerateConfirmEmailChangeLink and ApplicationOptions.ConfirmEmailChangeFormPath (default /confirm-email-change). No tenantIdentifier embedded. - New errors Email.NoPendingChange and Email.PendingMismatch. - UpdateUserInfoResult is internal sealed; both 200 and 202 carry empty bodies, so the result type does not leak into the API surface. Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- Idmt.Plugin/Configuration/IdmtOptions.cs | 1 + Idmt.Plugin/Errors/IdmtErrors.cs | 8 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Features/Auth/ConfirmEmailChange.cs | 148 ++++++ Idmt.Plugin/Features/AuthEndpoints.cs | 1 + Idmt.Plugin/Features/Manage/UpdateUserInfo.cs | 129 ++--- Idmt.Plugin/Services/IdmtLinkGenerator.cs | 45 ++ .../ConfirmEmailChangeRequestValidator.cs | 23 + .../ConfirmEmailChangeIntegrationTests.cs | 174 +++++++ .../Manage/UpdateUserInfoEmailChangeTests.cs | 280 +++++++++++ .../Auth/ConfirmEmailChangeHandlerTests.cs | 200 ++++++++ .../Manage/UpdateUserInfoHandlerTests.cs | 473 +++++++++++++----- .../Validation/FluentValidatorTests.cs | 51 ++ 13 files changed, 1350 insertions(+), 184 deletions(-) create mode 100644 Idmt.Plugin/Features/Auth/ConfirmEmailChange.cs create mode 100644 Idmt.Plugin/Validation/ConfirmEmailChangeRequestValidator.cs create mode 100644 tests/Idmt.BasicSample.Tests/Auth/ConfirmEmailChangeIntegrationTests.cs create mode 100644 tests/Idmt.BasicSample.Tests/Manage/UpdateUserInfoEmailChangeTests.cs create mode 100644 tests/Idmt.UnitTests/Features/Auth/ConfirmEmailChangeHandlerTests.cs 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 05f84b2..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 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/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/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/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/Services/IdmtLinkGenerator.cs b/Idmt.Plugin/Services/IdmtLinkGenerator.cs index 411bc01..0ebde0a 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( @@ -64,6 +72,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) 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/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/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.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/Manage/UpdateUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs index 5e8fd15..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", - 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", - - 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", - - 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"); - // The link generator must be called with the NEW email address and the fresh confirm token + var request = new UpdateUserInfo.UpdateUserInfoRequest(NewEmail: "new@test.com"); + + // 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", - - 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"); + + // Act + var result = await _handler.HandleAsync(request, principal); - // UpdateAsync must NOT be called — ChangeEmailAsync already saved the email change - _userManagerMock.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + // 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", + var user = await SeedUserAsync(email: "old@test.com", username: "testuser", emailConfirmed: true); + _userManagerMock.Setup(x => x.FindByEmailAsync("old@test.com")).ReturnsAsync(user); - IsActive = true, - EmailConfirmed = true - }; + // 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(() => + { + 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); + + 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); + .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("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"); - _userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); + .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"); + 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", - - 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", - - 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("old@test.com"); - var user = new IdmtUser - { - UserName = "testuser", - Email = "old@test.com", + 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" })); - IsActive = true, - EmailConfirmed = true - }; + 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 = 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/Validation/FluentValidatorTests.cs b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs index 59adad7..efc77ca 100644 --- a/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs +++ b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs @@ -162,6 +162,57 @@ public void ConfirmEmailRequestValidator_Fails_WithEmptyFields() #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] From c7b2ed10bbdc751358c8ee075c03ced11c29cc26 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 11:01:56 -0300 Subject: [PATCH 12/19] refactor(links)!: strip tenantIdentifier from confirm/reset URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: GenerateConfirmEmailLink and GeneratePasswordResetLink no longer embed tenantIdentifier as a query parameter in either the ServerConfirm or ClientForm branches. After Steps 5 and 6 the request records also stopped accepting it, so any consumer SPA that read tenantIdentifier from the link URL and echoed it back in the body must switch to host/path-based routing — the body field is silently ignored, the query param is gone. BuildClientFormUrl loses its tenantIdentifier parameter; both callers updated. Route-based tenant strategies still inject the configured route token (default __tenant__) as a path value, so /{tenant}/... links are unaffected. The hardened AddTenantRouteParameter guard skips injection when a custom route strategy is configured under the literal name "tenantIdentifier" — a stricter version of an existing accepted carry-forward (custom names other than the literal can still leak as ?=, documented inline). Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- Idmt.Plugin/Services/IdmtLinkGenerator.cs | 22 ++++++--- .../Services/CoreServicesTests.cs | 6 ++- .../Services/IdmtLinkGeneratorTests.cs | 49 +++++++++++++++++-- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/Idmt.Plugin/Services/IdmtLinkGenerator.cs b/Idmt.Plugin/Services/IdmtLinkGenerator.cs index 0ebde0a..1760a4b 100644 --- a/Idmt.Plugin/Services/IdmtLinkGenerator.cs +++ b/Idmt.Plugin/Services/IdmtLinkGenerator.cs @@ -42,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) @@ -60,7 +63,6 @@ public string GenerateConfirmEmailLink(string email, string token) url = BuildClientFormUrl( options.Value.Application.ClientUrl, options.Value.Application.ConfirmEmailFormPath, - tenantIdentifier, email, encodedToken); } @@ -117,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); @@ -133,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, }; @@ -157,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/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() { From 1aa5235855f184b2ef9e4c413a6da6950022a177 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 11:28:14 -0300 Subject: [PATCH 13/19] feat(admin)!: shrink default roles + bootstrap invoker tenant access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: IdmtDefaultRoleTypes.DefaultRoles no longer contains SysAdmin or SysSupport — those identities are now expressed via IdmtUser.SysRole and projected as role claims at sign-in. The string constants IdmtDefaultRoleTypes.SysAdmin / SysSupport remain so existing RequireRole policies and the SysRoleKind.ToString() equivalence keep holding. CreateTenant no longer seeds those two roles into new tenants; existing per-tenant rows from older deployments become inert (no users should be assigned to them after migration). CreateTenantHandler now requires ICurrentUserService and inserts a TenantAccess(invokerUserId, newTenantId, IsActive=true) row in the same inner-scope transaction as default-role seeding. This closes the chicken-and-egg HS-4: under the uniform TenantAccess gate a SysAdmin who created a tenant could not subsequently reach it. invokerUserId is captured outside ExecuteInTenantScopeAsync (V2-CRIT-2) — the inner DI scope's CurrentUserService.User is null by design (invariant #11). Bootstrap is wrapped in BeginTransactionAsync/CommitAsync; if role creation or the TenantAccess insert fails, both roll back atomically. A defensive guard rejects creation when the tenant store does not populate IdmtTenantInfo.Id after AddAsync (would otherwise insert a TenantAccess row with TenantId = null). GetUserInfoHandler now surfaces SysRole as a role string when the user has no per-tenant IdentityRole rows. SysAdmins (whose only "role" is SysRole=SysAdmin and not a per-tenant assignment) used to fail authorisation against /manage/info with NoRolesAssigned; the response now includes the union of per-tenant roles and the SysRole projection. Production SeedDefaultDataAsync was rewritten to seed the default tenant directly via IMultiTenantStore + ITenantOperationService rather than through the now-auth-gated handler — boot has no invoker context and the handler's fail-closed correctly refuses. Pre-existing direct-handler integration tests for CreateTenant and DeleteTenant in AdminIntegrationTests + MultiTenancyIntegrationTests were converted to HTTP-driven flows since the handler can no longer be invoked without a current user. 3 cross-tenant login tests remain failing by design (KR-1, deferred to the login-gate step). Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- .../ApplicationBuilderExtensions.cs | 64 ++++++- Idmt.Plugin/Features/Admin/CreateTenant.cs | 83 ++++++++- Idmt.Plugin/Features/Manage/GetUserInfo.cs | 14 +- Idmt.Plugin/Models/IdmtRole.cs | 9 +- .../Admin/CreateTenantInvokerAccessTests.cs | 147 +++++++++++++++ .../AdminIntegrationTests.cs | 74 +++++--- .../Idmt.BasicSample.Tests/IdmtApiFactory.cs | 10 + .../MultiTenancyIntegrationTests.cs | 25 ++- .../Admin/CreateTenantHandlerTests.cs | 175 ++++++++++++++++++ .../Manage/GetUserInfoHandlerTests.cs | 53 ++++++ 10 files changed, 604 insertions(+), 50 deletions(-) create mode 100644 tests/Idmt.BasicSample.Tests/Admin/CreateTenantInvokerAccessTests.cs 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/Features/Admin/CreateTenant.cs b/Idmt.Plugin/Features/Admin/CreateTenant.cs index 5f1eec9..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); diff --git a/Idmt.Plugin/Features/Manage/GetUserInfo.cs b/Idmt.Plugin/Features/Manage/GetUserInfo.cs index 9180c19..93b82a2 100644 --- a/Idmt.Plugin/Features/Manage/GetUserInfo.cs +++ b/Idmt.Plugin/Features/Manage/GetUserInfo.cs @@ -46,8 +46,18 @@ 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(); // Phase 1: tenant is sourced from ambient context — IdmtUser is global and no // longer carries TenantId. 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/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/AdminIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 7c6bf02..d80b3da 100644 --- a/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -45,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 @@ -86,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); } diff --git a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index 4cf2fd8..28bb252 100644 --- a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs +++ b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs @@ -166,6 +166,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, diff --git a/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index 65a1e0b..9677b23 100644 --- a/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -27,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) @@ -211,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); 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/Manage/GetUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs index f7e8029..99a89e6 100644 --- a/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs @@ -174,6 +174,59 @@ public async Task ReturnsAllRoles_SortedAlphabetically_WhenUserHasMultipleRoles( 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([ From ac0f0e1708f5775c68423f9c99a2838775447aaa Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 11:38:09 -0300 Subject: [PATCH 14/19] feat(auth)!: enforce TenantAccess gate at login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: 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 (so a wrong password still returns the same Auth.Unauthorized as a tenant-mismatch — no enumeration oracle) and before any cookie/token is issued. Closes KR-1: under canonical IdmtUser without this gate a user with credentials in tenant A could log in to tenant B simply by hitting B's login endpoint. The TenantAccess gate is uniform per locked decision #4 — SysRole does not short-circuit. Five sanity tests pin this invariant against TenantAccessService directly so a future contributor adding a SysRole fast-path for ergonomics fails CI. RegisterUser now writes the inviting tenant's TenantAccess row in the same transaction as user creation. Without this, a user who registers via /manage/users could not subsequently log in (the new gate would reject them on the very next request). The Add is safe against unique collisions because UserManager.CreateAsync rejects duplicate emails before reaching the TenantAccess insert. AdminIntegrationTests' GetUserTenants assertion was updated to reflect the new invariant (freshly registered user has exactly one TenantAccess for the registering tenant, not zero). Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- Idmt.Plugin/Features/Auth/Login.cs | 24 +++- Idmt.Plugin/Features/Manage/RegisterUser.cs | 12 ++ .../AdminIntegrationTests.cs | 10 +- .../AuthIntegrationTests.cs | 57 +++++++++ .../Features/Auth/LoginHandlerTests.cs | 53 ++++++++ .../Features/Auth/TokenLoginHandlerTests.cs | 55 ++++++++ .../Services/TenantAccessServiceTests.cs | 120 ++++++++++++++++++ 7 files changed, 323 insertions(+), 8 deletions(-) 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/Manage/RegisterUser.cs b/Idmt.Plugin/Features/Manage/RegisterUser.cs index 64707b4..0f0f188 100644 --- a/Idmt.Plugin/Features/Manage/RegisterUser.cs +++ b/Idmt.Plugin/Features/Manage/RegisterUser.cs @@ -100,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/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index d80b3da..41df16c 100644 --- a/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -369,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, @@ -383,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] diff --git a/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs index f9f6fe9..b2fba68 100644 --- a/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs @@ -105,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) @@ -195,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() { diff --git a/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs index a99492b..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); @@ -272,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/TokenLoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs index 9a63cfb..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); @@ -196,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/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); + } } From d59be070f1b07994c69f384c168a1024b9c55171 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 12:02:49 -0300 Subject: [PATCH 15/19] feat(migration): add canonical identity data migrator harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 ships a CLI harness for moving an existing deployment from the shadow-row model to the canonical model. The migrator is a documented harness, not a production-grade tool — it covers the happy path, the F42 security invariant (pre-migration bearer rejected after SecurityStamp rotation), and the dry-run/apply ack handshake. CanonicalIdentityDataMigrator (Idmt.Plugin/Migration/): - DryRunAsync groups duplicate IdmtUsers by NormalizedEmail, picks the oldest GUID v7 as the canonical Id, folds SysRole, and emits a SHA-256 fingerprint of the migration plan. - ApplyAsync refuses to run unless its computed fingerprint matches the operator-supplied --ack-dryrun-fingerprint. The whole mutation block (TenantAccess UserId rewrite, IdmtAuditLog UserId rewrite, IdentityUserRole + AspNetUserTokens rewrites, RevokedToken legacy row deletion, duplicate IdmtUser deletion, SysRole fold, per-survivor SecurityStamp rotation) is wrapped in BeginTransactionAsync / CommitAsync. Bulk ExecuteUpdate / ExecuteDelete operations participate in the ambient transaction, so a SaveChangesAsync failure rolls everything back. - MigrationCurrentUserService stubs ICurrentUserService for the context-less migration path so audit emission does not NRE. AddIdmtMigration replaces the runtime ICurrentUserService registration with the stub. tools/Idmt.Migrator: net10.0 console host. Args: --dry-run, --apply, --ack-dryrun-fingerprint , --accept-cross-tenant-merges , --provider {sqlite,sqlserver}. Trailing value-taking switches now emit a clear "missing value for flag" error rather than the misleading "unknown argument" fallback. Tests: 12 unit cases against an in-memory SQLite fixture exercise the dry-run determinism, ack handshake, individual rewrites, SysRole fold, stamp rotation, and an explicit Apply_SaveChangesAsyncFails_RollsBackBulkOperations regression that triggers a unique-index collision after a bulk delete and asserts the row is restored. F42 integration test mints a bearer ticket via the live login flow, runs the migrator's stamp rotation against the canonical user, and asserts the previously-issued refresh token is rejected — closes KR-2 / V2-CRIT-3 alternative path. Documented residuals: F41 (Phase 0 DDL snapshot fidelity test) and F47 (audit-emission exact-count assertion) deferred per harness scope; rationale captured in MigrationApplyTests class header. The --accept-cross-tenant-merges flag is wired but currently merges all duplicate groups unconditionally — reserved for future hardening. Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- .../CanonicalIdentityDataMigrator.cs | 379 +++++++++++++++++ .../Migration/MigrationCurrentUserService.cs | 45 +++ .../MigrationServiceCollectionExtensions.cs | 34 ++ Idmt.slnx | 3 + .../Idmt.BasicSample.Tests/IdmtApiFactory.cs | 6 + .../Migration/MigrationApplyTests.cs | 113 ++++++ .../CanonicalIdentityDataMigratorTests.cs | 382 ++++++++++++++++++ tools/Idmt.Migrator/Idmt.Migrator.csproj | 28 ++ tools/Idmt.Migrator/Program.cs | 215 ++++++++++ 9 files changed, 1205 insertions(+) create mode 100644 Idmt.Plugin/Migration/CanonicalIdentityDataMigrator.cs create mode 100644 Idmt.Plugin/Migration/MigrationCurrentUserService.cs create mode 100644 Idmt.Plugin/Migration/MigrationServiceCollectionExtensions.cs create mode 100644 tests/Idmt.BasicSample.Tests/Migration/MigrationApplyTests.cs create mode 100644 tests/Idmt.UnitTests/Migration/CanonicalIdentityDataMigratorTests.cs create mode 100644 tools/Idmt.Migrator/Idmt.Migrator.csproj create mode 100644 tools/Idmt.Migrator/Program.cs 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.slnx b/Idmt.slnx index 46ddcb0..78a9b37 100644 --- a/Idmt.slnx +++ b/Idmt.slnx @@ -6,5 +6,8 @@ + + + diff --git a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index 28bb252..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() 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.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/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 + { + } +} From 2dbfd0c90d2cd44cd02e29af19ea62f01091e915 Mon Sep 17 00:00:00 2001 From: idotta Date: Wed, 29 Apr 2026 12:08:32 -0300 Subject: [PATCH 16/19] chore(release): cut 2.0.0; document Phase 1 in CHANGELOG and CLAUDE.md Bumps Idmt.Plugin to 2.0.0 to reflect the breaking schema, API, and behavioural changes shipped over the previous 11 commits. CLAUDE.md's Multi-Tenancy and Authentication sections are rewritten so future contributors and AI assistants see the canonical model rather than the shadow-row description that pre-dated this work. CHANGELOG.md (new) captures the breaking change list, the new surface, the consumer migration runbook, the security-audit findings closed (C3, C4, C7, N1, N3, H7), and the residual risks that intentionally roll into Phase 2+ (KR-1 access-token expiry window, KR-3 EmailConfirmed=false bearer continuation, R18 email-spam vector pending rate-limit policy). Refs SECURITY_PHASE_1_CANONICAL_IDENTITY.md --- CHANGELOG.md | 196 +++++++++++++++++++++++++++++++++ CLAUDE.md | 99 +++++++++++++++++ Idmt.Plugin/Idmt.Plugin.csproj | 1 + 3 files changed, 296 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md 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/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 From 8bc3a601385a00a8cf3dbc734fb2a0f50c7c2fac Mon Sep 17 00:00:00 2001 From: idotta Date: Fri, 5 Jun 2026 10:03:49 -0300 Subject: [PATCH 17/19] docs(adr): add IDMT v2 OpenIddict authorization design + prototype spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the v2 authorization-layer design and the throwaway spike that gates it. - adr/0002: "own the policy, rent the protocol" — OpenIddict as the protocol engine (reference tokens, per-request revocation, BFF session for browsers), IDMT owning the canonical-identity/TenantAccess/SysRole policy. Support tokens mint server-side via IOpenIddictTokenManager.CreateAsync inside an IDMT-owned transaction (not a public RFC 8693 grant) so the audit row shares the token-store transaction. - adr/0001 + 0002 sketches + evaluation: supporting design record. - SECURITY_AUDIT + phase docs: the findings motivating the rewrite. - spike/: standalone host + xUnit project (out of Idmt.slnx/CI) proving §7.0 gates 1-4 on real infra — dual-context composition, instant reference-token revocation, per-request audience binding, and same-transaction support-audit atomicity. 8/8 green. --- SECURITY_AUDIT.md | 453 +++++++ SECURITY_PHASE_0_FOUNDATION.md | 159 +++ SECURITY_PHASE_0_IMPLEMENTATION.md | 340 +++++ SECURITY_PHASE_1_CANONICAL_IDENTITY.md | 236 ++++ SECURITY_PHASE_2_BEARER_COHERENCE.md | 313 +++++ SECURITY_PHASE_3_MIDDLEWARE_CONFIG.md | 426 +++++++ SECURITY_PHASE_4_HYGIENE.md | 422 ++++++ ...01-canonical-identity-and-tenant-access.md | 330 +++++ ...-idmt-v2-openiddict-authorization-layer.md | 676 ++++++++++ adr/0002-v2-evaluation.md | 142 +++ adr/0002-v2-sketch-architect-reviewer.md | 502 ++++++++ adr/0002-v2-sketch-code-architect.md | 1126 +++++++++++++++++ adr/0002-v2-sketch-dotnet-expert.md | 551 ++++++++ spike/Idmt.Spike.slnx | 8 + spike/src/Idmt.Spike.Host/Auth/Auth.cs | 93 ++ spike/src/Idmt.Spike.Host/Domain/Domain.cs | 88 ++ .../Idmt.Spike.Host/Idmt.Spike.Host.csproj | 19 + .../Idmt.Spike.Host/Persistence/Contexts.cs | 57 + spike/src/Idmt.Spike.Host/Program.cs | 84 ++ .../Properties/launchSettings.json | 23 + .../Seeding/IdmtSpikeSeeder.cs | 78 ++ .../Server/SupportTokenService.cs | 101 ++ .../src/Idmt.Spike.Host/Wiring/SpikeWiring.cs | 116 ++ .../appsettings.Development.json | 8 + spike/src/Idmt.Spike.Host/appsettings.json | 9 + .../BaseSpikeIntegrationTest.cs | 53 + .../Gate1_ReferenceTokenRevocationTests.cs | 40 + .../Gate2_TokenExchangeAuditAtomicityTests.cs | 91 ++ .../Gate3_AudienceHandlerTests.cs | 36 + .../Gate4_DualContextCompositionTests.cs | 58 + .../Idmt.Spike.Tests/Idmt.Spike.Tests.csproj | 29 + 31 files changed, 6667 insertions(+) create mode 100644 SECURITY_AUDIT.md create mode 100644 SECURITY_PHASE_0_FOUNDATION.md create mode 100644 SECURITY_PHASE_0_IMPLEMENTATION.md create mode 100644 SECURITY_PHASE_1_CANONICAL_IDENTITY.md create mode 100644 SECURITY_PHASE_2_BEARER_COHERENCE.md create mode 100644 SECURITY_PHASE_3_MIDDLEWARE_CONFIG.md create mode 100644 SECURITY_PHASE_4_HYGIENE.md create mode 100644 adr/0001-canonical-identity-and-tenant-access.md create mode 100644 adr/0002-idmt-v2-openiddict-authorization-layer.md create mode 100644 adr/0002-v2-evaluation.md create mode 100644 adr/0002-v2-sketch-architect-reviewer.md create mode 100644 adr/0002-v2-sketch-code-architect.md create mode 100644 adr/0002-v2-sketch-dotnet-expert.md create mode 100644 spike/Idmt.Spike.slnx create mode 100644 spike/src/Idmt.Spike.Host/Auth/Auth.cs create mode 100644 spike/src/Idmt.Spike.Host/Domain/Domain.cs create mode 100644 spike/src/Idmt.Spike.Host/Idmt.Spike.Host.csproj create mode 100644 spike/src/Idmt.Spike.Host/Persistence/Contexts.cs create mode 100644 spike/src/Idmt.Spike.Host/Program.cs create mode 100644 spike/src/Idmt.Spike.Host/Properties/launchSettings.json create mode 100644 spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs create mode 100644 spike/src/Idmt.Spike.Host/Server/SupportTokenService.cs create mode 100644 spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs create mode 100644 spike/src/Idmt.Spike.Host/appsettings.Development.json create mode 100644 spike/src/Idmt.Spike.Host/appsettings.json create mode 100644 spike/tests/Idmt.Spike.Tests/BaseSpikeIntegrationTest.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate1_ReferenceTokenRevocationTests.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate2_TokenExchangeAuditAtomicityTests.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate3_AudienceHandlerTests.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate4_DualContextCompositionTests.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Idmt.Spike.Tests.csproj 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..912cc0b --- /dev/null +++ b/adr/0002-idmt-v2-openiddict-authorization-layer.md @@ -0,0 +1,676 @@ +# ADR 0002 — IDMT v2: OpenIddict-based multi-tenant authorization layer + +- **Status:** Proposed — pending prototype gate (see [§7](#7-prototype-gate-and-open-questions)) +- **Date:** June 4, 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) that enumerates the user's tokens through +`IOpenIddictTokenManager.FindBySubjectAsync` and revokes each with +`TryRevokeAsync`. OpenIddict exposes no single-call `RevokeBySubjectAsync` and no +revoke-by-audience overload, so dropping a single tenant's tokens when +`TenantAccess` is revoked is the same enumeration filtered by the audience +recorded on each token entry before revoking the matches. 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. The enumerate-filter- +by-audience-and-revoke path is on the [§7.0](#70-prototype-gate-precondition-to-ratification) +prototype gate, because it is a non-trivial hook rather than a one-call primitive +and its cost grows with the number of tokens a user holds. + +### 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 enumerates and revokes a + user's tokens ([§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 cannot be settled on paper. A prototype spike must pass before this +ADR moves from Proposed to Accepted. The items after it are 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 by enumerating + `FindBySubjectAsync` and calling `TryRevokeAsync`, and the single-tenant variant + filters that enumeration by each token entry's audience before revoking, with + acceptable cost for a user holding many tokens. +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 missing + anti-forgery token on a cookie-bearing cross-site request 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. + +### 7.1 Open questions + +The following remain undecided and are tracked separately from the gate. + +- **First-party and machine-client authentication** without the password grant. + Decide between the client-credentials flow, a code exchange, or both. +- **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/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/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..711c8aa --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Program.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Finbuckle.MultiTenant.AspNetCore.Extensions; +using Idmt.Spike.Host.Auth; +using Idmt.Spike.Host.Seeding; +using Idmt.Spike.Host.Server; +using Idmt.Spike.Host.Wiring; +using Microsoft.AspNetCore; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddIdmtSpike(); +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(); +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", (HttpContext ctx) => +{ + var request = ctx.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("Not an OpenIddict token request."); + + 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.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..c275ebf --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs @@ -0,0 +1,78 @@ +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"; + + public const string TenantA = "acme"; + public const string TenantB = "globex"; + + public const string SysAdminEmail = "sysadmin@example.com"; + + 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); + } + + // 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); + } + } +} 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/Wiring/SpikeWiring.cs b/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs new file mode 100644 index 0000000..1cd397b --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs @@ -0,0 +1,116 @@ +using Finbuckle.MultiTenant.AspNetCore.Extensions; +using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; +using Finbuckle.MultiTenant.Extensions; +using Idmt.Spike.Host.Auth; +using Idmt.Spike.Host.Domain; +using Idmt.Spike.Host.Persistence; +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.AllowClientCredentialsFlow(); + o.AllowRefreshTokenFlow(); + // 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() + .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(); + + services.AddAuthentication(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + services.AddAuthorization(); + + 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/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 From 8fe5636f5428a8e05ca06f77866276d4036b3d27 Mon Sep 17 00:00:00 2001 From: idotta Date: Fri, 5 Jun 2026 13:41:08 -0300 Subject: [PATCH 18/19] =?UTF-8?q?feat(spike):=20prove=20=C2=A77.0=20gates?= =?UTF-8?q?=205,=206,=207=20and=20ratify=20ADR-0002?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of the OpenIddict spike proves the remaining prototype-gate items on real infrastructure (.NET 10, OpenIddict 7.5.0, Finbuckle 10.0.3, SQLite); the full suite is 16/16. ADR-0002 flips Proposed -> Accepted with the corrections the spike surfaced. - Gate 6 (SecurityStamp revocation): all-user revoke via RevokeBySubjectAsync; single-tenant revoke via RevokeByAuthorizationIdAsync on a per-(user,tenant) OpenIddict authorization. A token entry has no audience column (audience lives in the encrypted payload), so single-tenant uses authorization grouping, not an audience filter. UserTokenMint is the shared prerequisite. Proven on a 100-token user; cost is one store call. - Gate 5 (anti-subtraction seam): a last-wins PostConfigure lock re-clamps locked options, and IdmtSelfCheckStartupFilter throws a typed IdmtSecurityInvariantException at host start if an invariant was subtracted after the lock. - Gate 7 (BFF): a server-side session store keeps the reference token; the cookie is only an opaque session id. A resolver maps cookie -> bearer before authentication so the cookie path runs the same audience handler a raw bearer does; a mutating request without an anti-forgery token is rejected. The session token is acquired by a client-credentials back-channel stand-in (subject=client; user identity in the session); auth-code+PKCE stays a §7.1 open question. ADR §2.7 corrected (RevokeBySubjectAsync exists in 7.5.0; no audience column), §2.9 locked-set entry updated, §7.0 records the prototype outcome and the scoped stand-ins, Status -> Accepted. --- ...-idmt-v2-openiddict-authorization-layer.md | 82 ++++--- spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs | 208 ++++++++++++++++++ spike/src/Idmt.Spike.Host/Program.cs | 9 + .../Seeding/IdmtSpikeSeeder.cs | 16 ++ .../Server/TokenRevocationHook.cs | 43 ++++ .../Idmt.Spike.Host/Server/UserTokenMint.cs | 94 ++++++++ .../Wiring/IdmtSelfCheckStartupFilter.cs | 64 ++++++ .../src/Idmt.Spike.Host/Wiring/SpikeWiring.cs | 14 ++ .../Idmt.Spike.Tests/Gate5_SelfCheckTests.cs | 71 ++++++ .../Gate6_SecurityStampRevocationTests.cs | 135 ++++++++++++ .../Idmt.Spike.Tests/Gate7_BffSessionTests.cs | 134 +++++++++++ 11 files changed, 844 insertions(+), 26 deletions(-) create mode 100644 spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs create mode 100644 spike/src/Idmt.Spike.Host/Server/TokenRevocationHook.cs create mode 100644 spike/src/Idmt.Spike.Host/Server/UserTokenMint.cs create mode 100644 spike/src/Idmt.Spike.Host/Wiring/IdmtSelfCheckStartupFilter.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate5_SelfCheckTests.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate6_SecurityStampRevocationTests.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate7_BffSessionTests.cs diff --git a/adr/0002-idmt-v2-openiddict-authorization-layer.md b/adr/0002-idmt-v2-openiddict-authorization-layer.md index 912cc0b..604cda6 100644 --- a/adr/0002-idmt-v2-openiddict-authorization-layer.md +++ b/adr/0002-idmt-v2-openiddict-authorization-layer.md @@ -1,7 +1,7 @@ # ADR 0002 — IDMT v2: OpenIddict-based multi-tenant authorization layer -- **Status:** Proposed — pending prototype gate (see [§7](#7-prototype-gate-and-open-questions)) -- **Date:** June 4, 2026 +- **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 @@ -283,17 +283,18 @@ 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) that enumerates the user's tokens through -`IOpenIddictTokenManager.FindBySubjectAsync` and revokes each with -`TryRevokeAsync`. OpenIddict exposes no single-call `RevokeBySubjectAsync` and no -revoke-by-audience overload, so dropping a single tenant's tokens when -`TenantAccess` is revoked is the same enumeration filtered by the audience -recorded on each token entry before revoking the matches. 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. The enumerate-filter- -by-audience-and-revoke path is on the [§7.0](#70-prototype-gate-precondition-to-ratification) -prototype gate, because it is a non-trivial hook rather than a one-call primitive -and its cost grows with the number of tokens a user holds. +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 @@ -362,8 +363,10 @@ The locked set, enforced in `Build()`: - 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 enumerates and revokes a - user's tokens ([§2.7](#27-canonical-identity-carried-from-adr-0001)). +- 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 @@ -591,9 +594,10 @@ decision is auditable later. Two reviews — an adversarial critic and a validating architect — confirmed that several load-bearing claims about how OpenIddict, Finbuckle, and Entity Framework -Core compose cannot be settled on paper. A prototype spike must pass before this -ADR moves from Proposed to Accepted. The items after it are genuinely open and -must not be settled silently during implementation. +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) @@ -617,24 +621,50 @@ version, that: 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 by enumerating - `FindBySubjectAsync` and calling `TryRevokeAsync`, and the single-tenant variant - filters that enumeration by each token entry's audience before revoking, with - acceptable cost for a user holding many tokens. -7. A backend-for-frontend session cookie resolves to its server-side reference +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 missing - anti-forgery token on a cookie-bearing cross-site request is rejected. + 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 (16 tests). 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. The spike acquired the session's reference token via a + first-party client-credentials back-channel (subject = client; user identity in + the server-side session). Carrying subject = user in the token, and validating + the `SameSite` value against a real redirect-return, belong to the deferred + auth-code + PKCE work (§7.1). The single-instance topology proves revocation + correctness, not the bounded-staleness scale-out window (§7.1 backplane). + ### 7.1 Open questions The following remain undecided and are tracked separately from the gate. - **First-party and machine-client authentication** without the password grant. - Decide between the client-credentials flow, a code exchange, or both. + Decide between the client-credentials flow, a code exchange, or both. (The + prototype used client-credentials as a back-channel stand-in for the BFF session; + the production browser flow is auth-code + PKCE, still to be built.) - **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 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..47a2b51 --- /dev/null +++ b/spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs @@ -0,0 +1,208 @@ +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"; + + 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); + ctx.Response.Cookies.Append(CookieName, protectedId, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, // Strict would drop on the deferred auth-code redirect-return (§7.1). + Secure = false, // spike runs HTTP + IsEssential = true, + }); + + 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/Program.cs b/spike/src/Idmt.Spike.Host/Program.cs index 711c8aa..e056f6d 100644 --- a/spike/src/Idmt.Spike.Host/Program.cs +++ b/spike/src/Idmt.Spike.Host/Program.cs @@ -1,6 +1,7 @@ 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; @@ -12,6 +13,8 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddIdmtSpike(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -20,6 +23,10 @@ // 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(); @@ -79,6 +86,8 @@ })) .RequireAuthorization(); +app.MapBffEndpoints(); + app.Run(); public partial class Program; diff --git a/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs b/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs index c275ebf..c81d67b 100644 --- a/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs +++ b/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs @@ -18,6 +18,11 @@ public static class IdmtSpikeSeeder 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(); @@ -74,5 +79,16 @@ await apps.CreateAsync(new OpenIddictApplicationDescriptor 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/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 index 1cd397b..484e3ef 100644 --- a/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs +++ b/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs @@ -2,8 +2,10 @@ 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; @@ -108,9 +110,21 @@ public static IServiceCollection AddIdmtSpike(this IServiceCollection services) 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); services.AddAuthorization(); + services.AddBff(); + return services; } } 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"))); +} From dba2e430f16661bcc929f52b0666fffcbf0b594c Mon Sep 17 00:00:00 2001 From: idotta Date: Fri, 5 Jun 2026 14:42:17 -0300 Subject: [PATCH 19/19] =?UTF-8?q?feat(spike):=20prove=20gate=208=20?= =?UTF-8?q?=E2=80=94=20auth-code=20+=20PKCE=20browser=20login=20(subject?= =?UTF-8?q?=3Duser)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces gate 7's client-credentials back-channel stand-in with a real interactive authorization-code + PKCE flow, so the BFF session token carries subject = the authenticated user. Full spike suite 19/19. - /auth/login establishes an interactive authorization-server cookie session (AuthServerLogin), distinct from bff_session and the API validation scheme. - /connect/authorize (OpenIddict passthrough) issues a code for the logged-in user, audienced to the tenant via a custom 'tenant' param + SetAudiences (the standard RFC 8707 'resource' param trips OpenIddict invalid_target on an unregistered URN). /connect/token gains an authorization_code branch. - BFF /bff/login-pkce generates the S256 PKCE pair, stores the verifier server-side, and redirects; /bff/callback exchanges code+verifier via the back-channel and stores the reference token in the server-side session — the browser holds only the opaque bff_session cookie. Public spike-spa client (ClientType=Public) requires PKCE. - Gate 8 tests: full browser flow yields whoami subject = user id; the session runs the same audience handler (acme 200 / globex 401); a no-challenge authorize is rejected. Verified by mutation review. Security note: the spike's OAuth `state` is not bound to the initiating browser (login-CSRF) — documented in-code as a SPIKE LIMITATION and carried to the v2 plan as a hardening requirement; it is not a composition unknown this gate exists to prove. ADR §7.0 records the gate 8 outcome (19 tests, subject=user supersedes the gate 7 stand-in); §7.1 marks interactive first-party auth resolved to auth-code + PKCE, leaving machine-client auth and the scale-out backplane open. --- ...-idmt-v2-openiddict-authorization-layer.md | 30 +-- .../Idmt.Spike.Host/Bff/AuthCodeEndpoints.cs | 196 ++++++++++++++++++ spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs | 18 +- spike/src/Idmt.Spike.Host/Program.cs | 16 +- .../Seeding/IdmtSpikeSeeder.cs | 28 +++ .../src/Idmt.Spike.Host/Wiring/SpikeWiring.cs | 16 +- .../Gate8_AuthCodePkceTests.cs | 129 ++++++++++++ 7 files changed, 412 insertions(+), 21 deletions(-) create mode 100644 spike/src/Idmt.Spike.Host/Bff/AuthCodeEndpoints.cs create mode 100644 spike/tests/Idmt.Spike.Tests/Gate8_AuthCodePkceTests.cs diff --git a/adr/0002-idmt-v2-openiddict-authorization-layer.md b/adr/0002-idmt-v2-openiddict-authorization-layer.md index 604cda6..60e2520 100644 --- a/adr/0002-idmt-v2-openiddict-authorization-layer.md +++ b/adr/0002-idmt-v2-openiddict-authorization-layer.md @@ -638,8 +638,9 @@ 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 (16 tests). Corrections and scoped -stand-ins the spike surfaced, folded into this ADR: +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 @@ -650,21 +651,26 @@ stand-ins the spike surfaced, folded into this ADR: 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. The spike acquired the session's reference token via a - first-party client-credentials back-channel (subject = client; user identity in - the server-side session). Carrying subject = user in the token, and validating - the `SameSite` value against a real redirect-return, belong to the deferred - auth-code + PKCE work (§7.1). The single-instance topology proves revocation - correctness, not the bounded-staleness scale-out window (§7.1 backplane). + 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. -- **First-party and machine-client authentication** without the password grant. - Decide between the client-credentials flow, a code exchange, or both. (The - prototype used client-credentials as a back-channel stand-in for the BFF session; - the production browser flow is auth-code + PKCE, still to be built.) +- **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 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 index 47a2b51..1953849 100644 --- a/spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs +++ b/spike/src/Idmt.Spike.Host/Bff/BffEndpoints.cs @@ -30,6 +30,16 @@ 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"); @@ -104,13 +114,7 @@ public static void MapBffEndpoints(this WebApplication app) var sessionId = store.Create(user.Id, body.Tenant, token); var protectedId = dp.CreateProtector(ProtectorPurpose).Protect(sessionId); - ctx.Response.Cookies.Append(CookieName, protectedId, new CookieOptions - { - HttpOnly = true, - SameSite = SameSiteMode.Lax, // Strict would drop on the deferred auth-code redirect-return (§7.1). - Secure = false, // spike runs HTTP - IsEssential = true, - }); + AppendSessionCookie(ctx, protectedId); return Results.Ok(new LoginResponse()); }); diff --git a/spike/src/Idmt.Spike.Host/Program.cs b/spike/src/Idmt.Spike.Host/Program.cs index e056f6d..0adf2ae 100644 --- a/spike/src/Idmt.Spike.Host/Program.cs +++ b/spike/src/Idmt.Spike.Host/Program.cs @@ -6,6 +6,7 @@ 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; @@ -32,11 +33,23 @@ // Token endpoint: client-credentials passthrough. IDMT stamps the per-tenant // audience from the request "tenant" parameter (gates 1, 3, 4). -app.MapPost("/connect/token", (HttpContext ctx) => +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( @@ -87,6 +100,7 @@ .RequireAuthorization(); app.MapBffEndpoints(); +app.MapAuthCodeEndpoints(); app.Run(); diff --git a/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs b/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs index c81d67b..00ff734 100644 --- a/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs +++ b/spike/src/Idmt.Spike.Host/Seeding/IdmtSpikeSeeder.cs @@ -13,6 +13,10 @@ 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"; @@ -62,6 +66,30 @@ await apps.CreateAsync(new OpenIddictApplicationDescriptor }, 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(); diff --git a/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs b/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs index 484e3ef..faf4493 100644 --- a/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs +++ b/spike/src/Idmt.Spike.Host/Wiring/SpikeWiring.cs @@ -76,9 +76,12 @@ public static IServiceCollection AddIdmtSpike(this IServiceCollection services) .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 @@ -96,6 +99,7 @@ public static IServiceCollection AddIdmtSpike(this IServiceCollection services) o.UseAspNetCore() .EnableTokenEndpointPassthrough() + .EnableAuthorizationEndpointPassthrough() .DisableTransportSecurityRequirement(); // spike runs over HTTP }) .AddValidation(o => @@ -120,10 +124,20 @@ public static IServiceCollection AddIdmtSpike(this IServiceCollection services) // invariant was subtracted after the lock. services.AddTransient(); - services.AddAuthentication(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + 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/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); +}