From afa1d81cbc7655284b05b188b29c25cff36f2902 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 20 Aug 2026 14:15:59 -0700 Subject: [PATCH 1/5] AB#34137 Rework tenant creation: onboarding field mapping, New Tenant modal, Metabase post-creation step Onboarding "Create Tenant" modal: - Reorders field mapping so Name and the new Display Name field come first, then Ministry, Division, Branch, Program Area, Features, Program Managers. - Adds Display Name and Division as mappable fields (stored on the tenant's ExtraProperties / passed through to TenantCreateDto). - Fixes the Jaro-Winkler auto-detection matching a "ProgramManagerEmail"-style field to the Name canonical instead of a literal "Name" field, via an exact-match short-circuit before fuzzy scoring. New Tenant modal (Tenants/CreateModal): - Rebuilt to match the Edit Configuration modal's tabbed layout (Details, Program Managers, Features, Metabase) instead of a single flat form. - Details tab now actually renders Division/Branch/Description/CAS Client Code (previously present on the model but never shown) plus Display Name. - Program Managers search now uses the same field-selector UX as Edit. - Features tab lets you pick features to enable at creation time, reusing the existing FeatureKeys -> TenantCreatedEventHandler mechanism. - New Metabase tab manages the group's member emails, backed by ABP Settings (Global default list + per-tenant snapshot, with an "add for this tenant only" vs "save as default" option). Tenants list: adds a Display Name column. Post-tenant-creation pipeline (new): - IPostTenantCreationStep (Unity.SharedKernel) - pluggable, ordered, per-step ContinueOnError. - PostTenantCreationSequenceJob - a self-chaining ABP background job that runs steps in order, entirely via the job queue (durable/retryable per step), stopping on failure only where ContinueOnError is false. - MetabaseTenantRegistrationStep - first step. Registers the new tenant with Metabase (database connection over its readonly Postgres role, a permissions group with its configured members, and a collection), automating what manual_deploy_new_metabase_tenant.ps1 used to do by hand. Resolves the Metabase endpoint via the existing DynamicUrls mechanism (new METABASE_API_BASE key) and the API key via new TenantCreation: Steps:Metabase:ApiKey config. - IResilientHttpRequest gains an optional extraHeaders parameter (needed for Metabase's x-api-key auth), backward compatible with all existing call sites. Co-Authored-By: Claude Sonnet 5 --- .../Http/IResilientHttpRequest.cs | 4 +- .../Http/ResilientHttpRequest.cs | 22 +- .../IPostTenantCreationStep.cs | 27 +++ .../IOnboardingRequestAppService.cs | 2 +- .../Metabase/MetabaseSettings.cs | 11 + .../OnboardingColumnConfigDto.cs | 4 + .../OnboardingRequestDto.cs | 2 + .../TenantCreateDto.cs | 2 + .../TenantCreateOrUpdateDtoBase.cs | 1 + .../TenantDto.cs | 1 + .../MetabaseSettingDefinitionProvider.cs | 20 ++ ...ngColumnConfigSettingDefinitionProvider.cs | 2 + .../OnboardingColumnConfigSettings.cs | 2 + .../OnboardingRequestAppService.cs | 37 ++- .../TenantAppService.cs | 6 +- .../UnityTenantManagementMapperlyProfile.cs | 1 + .../OnboardingRequestController.cs | 6 +- .../Onboarding/CreateTenantModal.cshtml | 36 ++- .../TenantManagement/Onboarding/Index.js | 32 ++- .../Tenants/CreateModal.cshtml | 181 +++++++++++--- .../Tenants/CreateModal.cshtml.cs | 79 +++++-- .../Pages/TenantManagement/Tenants/Index.js | 221 ++++++++++++++---- ...UnityTenantManagementWebMapperlyProfile.cs | 5 +- .../GrantManagerApplicationModule.cs | 3 + .../Handlers/TenantCreatedEventHandler.cs | 34 ++- .../Metabase/IMetabaseApiClient.cs | 23 ++ .../Metabase/MetabaseApiClient.cs | 137 +++++++++++ .../Integrations/Metabase/MetabaseOptions.cs | 7 + .../PostTenantCreationSequenceJob.cs | 72 ++++++ .../PostTenantCreationStepArgs.cs | 12 + .../Steps/MetabaseTenantRegistrationStep.cs | 140 +++++++++++ .../Integrations/DynamicUrlKeyNames.cs | 1 + .../Localization/GrantManager/en.json | 7 +- .../Integrations/DynamicUrlDataSeeder.cs | 3 + .../Unity.GrantManager.Web/appsettings.json | 9 +- .../PostTenantCreationSequenceJobTests.cs | 123 ++++++++++ .../MetabaseTenantRegistrationStepTests.cs | 117 ++++++++++ 37 files changed, 1264 insertions(+), 128 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.SharedKernel/PostTenantCreation/IPostTenantCreationStep.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Metabase/MetabaseSettings.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Metabase/MetabaseSettingDefinitionProvider.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationStepArgs.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs index ea461b2d2c..e61844102a 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/IResilientHttpRequest.cs @@ -1,4 +1,5 @@ -using System.Net.Http; +using System.Collections.Generic; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Volo.Abp; @@ -18,6 +19,7 @@ Task HttpAsync( string? authToken = null, (string username, string password)? basicAuth = null, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + IReadOnlyDictionary? extraHeaders = null, CancellationToken cancellationToken = default); /// diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs index 109c0a816d..80814ccca6 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Http/ResilientHttpRequest.cs @@ -1,6 +1,7 @@ using Polly; using Polly.Retry; using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -114,10 +115,11 @@ public async Task HttpAsync( string? authToken = null, (string username, string password)? basicAuth = null, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + IReadOnlyDictionary? extraHeaders = null, CancellationToken cancellationToken = default) { return await SendWithClientAsync( - _httpClient, httpVerb, resource, body, authToken, basicAuth, completionOption, cancellationToken); + _httpClient, httpVerb, resource, body, authToken, basicAuth, completionOption, extraHeaders, cancellationToken); } @@ -138,7 +140,7 @@ public Task HttpAsyncSecured( EnsureMutualTlsClient(certPath, certPassword); return SendWithClientAsync( - _mtlsClient!, httpVerb, resource, body, authToken, basicAuth, HttpCompletionOption.ResponseContentRead, cancellationToken); + _mtlsClient!, httpVerb, resource, body, authToken, basicAuth, HttpCompletionOption.ResponseContentRead, null, cancellationToken); } @@ -193,6 +195,7 @@ private async Task SendWithClientAsync( string? authToken, (string username, string password)? basicAuth, HttpCompletionOption completionOption, + IReadOnlyDictionary? extraHeaders, CancellationToken cancellationToken) { // Build final URL @@ -208,7 +211,7 @@ private async Task SendWithClientAsync( return await _pipeline.ExecuteAsync(async ct => { using var requestMessage = - BuildRequestMessage(httpVerb, fullUrl, body, authToken, basicAuth); + BuildRequestMessage(httpVerb, fullUrl, body, authToken, basicAuth, extraHeaders); return await client.SendAsync(requestMessage, completionOption, ct) .ConfigureAwait(false); @@ -226,7 +229,8 @@ private static HttpRequestMessage BuildRequestMessage( Uri fullUrl, object? body, string? authToken, - (string username, string password)? basicAuth) + (string username, string password)? basicAuth, + IReadOnlyDictionary? extraHeaders = null) { var requestMessage = new HttpRequestMessage(httpVerb, fullUrl); requestMessage.Headers.Accept.Clear(); @@ -248,6 +252,16 @@ private static HttpRequestMessage BuildRequestMessage( requestMessage.Headers.Add(AuthorizationHeader, $"Basic {encoded}"); } + // Additional headers (e.g. API keys) not covered by the auth-token/basic-auth cases above + if (extraHeaders != null) + { + foreach (var header in extraHeaders) + { + requestMessage.Headers.Remove(header.Key); + requestMessage.Headers.Add(header.Key, header.Value); + } + } + // Body if (body != null) { diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/PostTenantCreation/IPostTenantCreationStep.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/PostTenantCreation/IPostTenantCreationStep.cs new file mode 100644 index 0000000000..28b328048f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/PostTenantCreation/IPostTenantCreationStep.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.PostTenantCreation; + +/// +/// A single step in the post-tenant-creation sequence, run as part of +/// PostTenantCreationSequenceJob after a new tenant is created. Implement this in any +/// module and register it as an to +/// have it picked up automatically - no changes to the sequencing job are needed. +/// +public interface IPostTenantCreationStep +{ + /// Determines execution order relative to other steps (ascending). + int Order { get; } + + /// Short, human-readable name used in logging. + string StepName { get; } + + /// + /// When true, a failure in this step is logged and the sequence continues to the next step. + /// When false, a failure stops the sequence - later steps do not run. + /// + bool ContinueOnError { get; } + + Task ExecuteAsync(Guid tenantId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs index 10cfd4acc0..24c0f6fcee 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs @@ -11,7 +11,7 @@ public interface IOnboardingRequestAppService : IApplicationService { Task> GetListAsync(OnboardingListRequestDto input); Task GetAsync(Guid id); - Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null); + Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null, string? displayNameFieldKey = null, string? divisionFieldKey = null); Task CreateTenantAsync(Guid id, CreateTenantInputDto? input); Task GetColumnSchemaAsync(string? category = null); Task> GetAvailableCategoriesAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Metabase/MetabaseSettings.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Metabase/MetabaseSettings.cs new file mode 100644 index 0000000000..3b59d51480 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Metabase/MetabaseSettings.cs @@ -0,0 +1,11 @@ +namespace Unity.TenantManagement.Metabase; + +public static class MetabaseSettings +{ + /// + /// Comma-separated list of user emails to add to a tenant's Metabase group. + /// Stored Global (the running default applied to new tenants) and per-tenant, "T" provider + /// (the resolved snapshot captured when that tenant was created). + /// + public const string UserEmails = "Metabase.UserEmails"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs index 2a95c6a0af..86ba5c63e4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs @@ -15,19 +15,23 @@ public class OnboardingColumnSchemaDto { public List Columns { get; set; } = []; public string? TenantNameFieldKey { get; set; } + public string? DisplayNameFieldKey { get; set; } public string? SuperUsersFieldKey { get; set; } public string? BranchFieldKey { get; set; } public string? FeaturesFieldKey { get; set; } public string? MinistryFieldKey { get; set; } + public string? DivisionFieldKey { get; set; } public string? ProgramAreaFieldKey { get; set; } } public class CreateTenantInputDto { public string? TenantNameFieldKey { get; set; } + public string? DisplayNameFieldKey { get; set; } public string? SuperUsersFieldKey { get; set; } public string? BranchFieldKey { get; set; } public string? FeaturesFieldKey { get; set; } public string? MinistryFieldKey { get; set; } + public string? DivisionFieldKey { get; set; } public string? ProgramAreaFieldKey { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs index c251999c11..4a773b44b2 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs @@ -9,6 +9,7 @@ public class OnboardingRequestDto public Guid Id { get; set; } public string SubmissionNumber { get; set; } = string.Empty; public string TenantName { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; public string TenantDescription { get; set; } = string.Empty; public string ProgramAreaName { get; set; } = string.Empty; public string ProgramAreaDescription { get; set; } = string.Empty; @@ -18,6 +19,7 @@ public class OnboardingRequestDto public string ExecutiveDirector { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Ministry { get; set; } = string.Empty; + public string Division { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public DateTime? SubmissionDate { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs index d9b7998e85..a751daa659 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs @@ -6,4 +6,6 @@ public class TenantCreateDto : TenantCreateOrUpdateDtoBase public string UserIdentifier { get; set; } = string.Empty; /// Comma-separated ABP feature keys to enable on the new tenant (e.g. "Unity.Payments,Unity.Reporting"). public string? FeatureKeys { get; set; } + /// Comma-separated user emails to add to this tenant's Metabase group. + public string? MetabaseUserEmails { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs index 0b89d2ddf3..33495c44ab 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateOrUpdateDtoBase.cs @@ -12,6 +12,7 @@ public abstract class TenantCreateOrUpdateDtoBase : ExtensibleObject [Display(Name = "TenantName")] public string Name { get; set; } + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs index 5dc338b00c..ef8d496847 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs @@ -7,6 +7,7 @@ namespace Unity.TenantManagement; public class TenantDto : ExtensibleEntityDto, IHasConcurrencyStamp { public string Name { get; set; } + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Metabase/MetabaseSettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Metabase/MetabaseSettingDefinitionProvider.cs new file mode 100644 index 0000000000..1f3874faed --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Metabase/MetabaseSettingDefinitionProvider.cs @@ -0,0 +1,20 @@ +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; + +namespace Unity.TenantManagement.Metabase; + +public class MetabaseSettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + MetabaseSettings.UserEmails, + defaultValue: null, + isVisibleToClients: false, + isInherited: false, + isEncrypted: false) + .WithProviders(GlobalSettingValueProvider.ProviderName, TenantSettingValueProvider.ProviderName) + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs index c0000d45fc..d9affde02e 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs @@ -13,10 +13,12 @@ public override void Define(ISettingDefinitionContext context) { context.Add( OnboardingDef(OnboardingColumnConfigSettings.TenantNameFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.DisplayNameFieldKey), OnboardingDef(OnboardingColumnConfigSettings.SuperUsersFieldKey), OnboardingDef(OnboardingColumnConfigSettings.BranchFieldKey), OnboardingDef(OnboardingColumnConfigSettings.FeaturesFieldKey), OnboardingDef(OnboardingColumnConfigSettings.MinistryFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.DivisionFieldKey), OnboardingDef(OnboardingColumnConfigSettings.ProgramAreaFieldKey) ); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs index a2041e1c22..49bc404a06 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs @@ -3,9 +3,11 @@ namespace Unity.TenantManagement.Onboarding; public static class OnboardingColumnConfigSettings { public const string TenantNameFieldKey = "Onboarding.ColumnConfig.TenantNameFieldKey"; + public const string DisplayNameFieldKey = "Onboarding.ColumnConfig.DisplayNameFieldKey"; public const string SuperUsersFieldKey = "Onboarding.ColumnConfig.SuperUsersFieldKey"; public const string BranchFieldKey = "Onboarding.ColumnConfig.BranchFieldKey"; public const string FeaturesFieldKey = "Onboarding.ColumnConfig.FeaturesFieldKey"; public const string MinistryFieldKey = "Onboarding.ColumnConfig.MinistryFieldKey"; + public const string DivisionFieldKey = "Onboarding.ColumnConfig.DivisionFieldKey"; public const string ProgramAreaFieldKey = "Onboarding.ColumnConfig.ProgramAreaFieldKey"; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs index 7b60631ad3..6e44749adb 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -256,13 +256,13 @@ public virtual async Task> GetAvailableCategoriesAsync() return categories; } - public virtual async Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null) + public virtual async Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null, string? displayNameFieldKey = null, string? divisionFieldKey = null) { var request = await GetAsync(id); if (request == null) return new OnboardingValidationResultDto { IsValid = false, Issues = ["Onboarding request not found."] }; - await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey, displayNameFieldKey, divisionFieldKey); var issues = await RunValidationStepsAsync(request); @@ -274,10 +274,10 @@ public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input var request = await GetAsync(id) ?? throw new UserFriendlyException("Onboarding request not found."); - await ResolveFieldMappings(request, input?.TenantNameFieldKey, input?.SuperUsersFieldKey, input?.BranchFieldKey, input?.FeaturesFieldKey, input?.MinistryFieldKey, input?.ProgramAreaFieldKey); + await ResolveFieldMappings(request, input?.TenantNameFieldKey, input?.SuperUsersFieldKey, input?.BranchFieldKey, input?.FeaturesFieldKey, input?.MinistryFieldKey, input?.ProgramAreaFieldKey, input?.DisplayNameFieldKey, input?.DivisionFieldKey); if (input != null) - await SaveFieldMappingAsync(input.TenantNameFieldKey, input.SuperUsersFieldKey, input.BranchFieldKey, input.FeaturesFieldKey, input.MinistryFieldKey, input.ProgramAreaFieldKey); + await SaveFieldMappingAsync(input.TenantNameFieldKey, input.SuperUsersFieldKey, input.BranchFieldKey, input.FeaturesFieldKey, input.MinistryFieldKey, input.ProgramAreaFieldKey, input.DisplayNameFieldKey, input.DivisionFieldKey); // Re-validate server-side even if the client already called ValidateAsync — the client // cannot be trusted to have done so, and skipping this would let a duplicate tenant name @@ -307,7 +307,9 @@ public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input var tenantDto = await TenantAppService.CreateAsync(new TenantCreateDto { Name = request.TenantName, + DisplayName = request.DisplayName, Branch = request.Branch, + Division = request.Division, Description = request.TenantDescription, UserIdentifier = userGuids[0], FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null @@ -341,7 +343,8 @@ private async Task> RunValidationStepsAsync(OnboardingRequestDto re private async Task ResolveFieldMappings(OnboardingRequestDto request, string? tenantNameKey = null, string? superUsersKey = null, string? branchKey = null, string? featuresKey = null, - string? ministryKey = null, string? programAreaKey = null) + string? ministryKey = null, string? programAreaKey = null, + string? displayNameKey = null, string? divisionKey = null) { var saved = await ReadTenantMappingAsync(); tenantNameKey ??= saved.TenantNameFieldKey; @@ -350,9 +353,13 @@ private async Task ResolveFieldMappings(OnboardingRequestDto request, featuresKey ??= saved.FeaturesFieldKey; ministryKey ??= saved.MinistryFieldKey; programAreaKey ??= saved.ProgramAreaFieldKey; + displayNameKey ??= saved.DisplayNameFieldKey; + divisionKey ??= saved.DivisionFieldKey; if (!string.IsNullOrEmpty(tenantNameKey) && request.Fields.TryGetValue(tenantNameKey, out var tenantNameVal) && tenantNameVal is not null) request.TenantName = tenantNameVal.ToString()!; + if (!string.IsNullOrEmpty(displayNameKey) && request.Fields.TryGetValue(displayNameKey, out var displayNameVal) && displayNameVal is not null) + request.DisplayName = displayNameVal.ToString()!; if (!string.IsNullOrEmpty(superUsersKey) && request.Fields.TryGetValue(superUsersKey, out var superUsersVal) && superUsersVal is not null) request.SuperUsers = superUsersVal.ToString()!; if (!string.IsNullOrEmpty(branchKey) && request.Fields.TryGetValue(branchKey, out var branchVal) && branchVal is not null) @@ -361,51 +368,61 @@ private async Task ResolveFieldMappings(OnboardingRequestDto request, request.Features = featuresVal.ToString()!; if (!string.IsNullOrEmpty(ministryKey) && request.Fields.TryGetValue(ministryKey, out var ministryVal) && ministryVal is not null) request.Ministry = ministryVal.ToString()!; + if (!string.IsNullOrEmpty(divisionKey) && request.Fields.TryGetValue(divisionKey, out var divisionVal) && divisionVal is not null) + request.Division = divisionVal.ToString()!; if (!string.IsNullOrEmpty(programAreaKey) && request.Fields.TryGetValue(programAreaKey, out var programAreaVal) && programAreaVal is not null) request.ProgramAreaName = programAreaVal.ToString()!; } - private async Task SaveFieldMappingAsync(string? tenantNameKey, string? superUsersKey, string? branchKey, string? featuresKey, string? ministryKey, string? programAreaKey) + private async Task SaveFieldMappingAsync(string? tenantNameKey, string? superUsersKey, string? branchKey, string? featuresKey, string? ministryKey, string? programAreaKey, string? displayNameKey, string? divisionKey) { var userId = CurrentUser.Id?.ToString(); if (string.IsNullOrEmpty(userId)) return; await _settingManager.SetAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, tenantNameKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.DisplayNameFieldKey, displayNameKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, superUsersKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.BranchFieldKey, branchKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, featuresKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.MinistryFieldKey, ministryKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.DivisionFieldKey, divisionKey, UserProvider, userId); await _settingManager.SetAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, programAreaKey, UserProvider, userId); } private async Task ReadTenantMappingAsync() { var userId = CurrentUser.Id?.ToString(); - string? tenantNameKey = null, superUsersKey = null, branchKey = null, featuresKey = null, ministryKey = null, programAreaKey = null; + string? tenantNameKey = null, displayNameKey = null, superUsersKey = null, branchKey = null, featuresKey = null, ministryKey = null, divisionKey = null, programAreaKey = null; if (!string.IsNullOrEmpty(userId)) { tenantNameKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, UserProvider, userId); + displayNameKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.DisplayNameFieldKey, UserProvider, userId); superUsersKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, UserProvider, userId); branchKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.BranchFieldKey, UserProvider, userId); featuresKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, UserProvider, userId); ministryKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.MinistryFieldKey, UserProvider, userId); + divisionKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.DivisionFieldKey, UserProvider, userId); programAreaKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, UserProvider, userId); } tenantNameKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.TenantNameFieldKey); + displayNameKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.DisplayNameFieldKey); superUsersKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey); branchKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.BranchFieldKey); featuresKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.FeaturesFieldKey); ministryKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.MinistryFieldKey); + divisionKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.DivisionFieldKey); programAreaKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey); return new OnboardingColumnSchemaDto { TenantNameFieldKey = tenantNameKey, + DisplayNameFieldKey = displayNameKey, SuperUsersFieldKey = superUsersKey, BranchFieldKey = branchKey, FeaturesFieldKey = featuresKey, MinistryFieldKey = ministryKey, + DivisionFieldKey = divisionKey, ProgramAreaFieldKey = programAreaKey }; } @@ -526,6 +543,7 @@ private static OnboardingRequestDto MapToDto( switch (fv.Key.ToLowerInvariant().Replace("-", "").Replace("_", "").Replace(" ", "")) { + case "displayname": dto.DisplayName = fv.Value; break; case "tenantdescription": case "description": dto.TenantDescription = fv.Value; break; case "programareaname": case "programarea": dto.ProgramAreaName = fv.Value; break; case "programareadescription": dto.ProgramAreaDescription = fv.Value; break; @@ -534,6 +552,7 @@ private static OnboardingRequestDto MapToDto( case "executivedirector": dto.ExecutiveDirector = fv.Value; break; case "branch": dto.Branch = fv.Value; break; case "ministry": dto.Ministry = fv.Value; break; + case "division": dto.Division = fv.Value; break; } } } @@ -545,6 +564,8 @@ private static OnboardingRequestDto MapToDto( if (!string.IsNullOrEmpty(mapping.TenantNameFieldKey) && dto.Fields.TryGetValue(mapping.TenantNameFieldKey, out var tn) && tn != null) dto.TenantName = tn.ToString()!; + if (!string.IsNullOrEmpty(mapping.DisplayNameFieldKey) && dto.Fields.TryGetValue(mapping.DisplayNameFieldKey, out var dn) && dn != null) + dto.DisplayName = dn.ToString()!; if (!string.IsNullOrEmpty(mapping.SuperUsersFieldKey) && dto.Fields.TryGetValue(mapping.SuperUsersFieldKey, out var su) && su != null) dto.SuperUsers = su.ToString()!; if (!string.IsNullOrEmpty(mapping.BranchFieldKey) && dto.Fields.TryGetValue(mapping.BranchFieldKey, out var br) && br != null) @@ -553,6 +574,8 @@ private static OnboardingRequestDto MapToDto( dto.Features = ft.ToString()!; if (!string.IsNullOrEmpty(mapping.MinistryFieldKey) && dto.Fields.TryGetValue(mapping.MinistryFieldKey, out var mn) && mn != null) dto.Ministry = mn.ToString()!; + if (!string.IsNullOrEmpty(mapping.DivisionFieldKey) && dto.Fields.TryGetValue(mapping.DivisionFieldKey, out var dv) && dv != null) + dto.Division = dv.ToString()!; if (!string.IsNullOrEmpty(mapping.ProgramAreaFieldKey) && dto.Fields.TryGetValue(mapping.ProgramAreaFieldKey, out var pa) && pa != null) dto.ProgramAreaName = pa.ToString()!; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 3e5bed3287..d2dc87ebc4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -34,6 +34,7 @@ public class TenantAppService( { private IIdentityUserRepository IdentityUserRepository => LazyServiceProvider.LazyGetRequiredService(); + private const string ExtraPropDisplayName = "DisplayName"; private const string ExtraPropDivision = "Division"; private const string ExtraPropBranch = "Branch"; private const string ExtraPropDescription = "Description"; @@ -169,6 +170,7 @@ public virtual async Task CreateAsync(TenantCreateDto input) // Set ExtraProperties from input tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; + tenant.ExtraProperties[ExtraPropDisplayName] = input.DisplayName ?? string.Empty; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; @@ -188,7 +190,8 @@ await localEventBus.PublishAsync( Properties = { { "UserIdentifier", input.UserIdentifier }, - { "FeatureKeys", input.FeatureKeys ?? string.Empty } + { "FeatureKeys", input.FeatureKeys ?? string.Empty }, + { "MetabaseUserEmails", input.MetabaseUserEmails ?? string.Empty } } } ); @@ -206,6 +209,7 @@ public virtual async Task UpdateAsync(Guid id, TenantUpdateDto input) tenant.SetConcurrencyStampIfNotNull(input.ConcurrencyStamp); // Update ExtraProperties from input + tenant.ExtraProperties[ExtraPropDisplayName] = input.DisplayName ?? string.Empty; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs index c0af541474..d42c060cd1 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs @@ -19,6 +19,7 @@ public override void Map(Tenant source, TenantDto destination) destination.Id = source.Id; destination.Name = source.Name; destination.ConcurrencyStamp = source.ConcurrencyStamp; + destination.DisplayName = GetExtraProperty(source, "DisplayName") ?? string.Empty; destination.CasClientCode = GetExtraProperty(source, "CasClientCode") ?? string.Empty; destination.LicencePlate = GetExtraProperty(source, "LicencePlate") ?? string.Empty; destination.Division = GetExtraProperty(source, "Division") ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs index e00ada72a8..8443ff3990 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs @@ -40,10 +40,12 @@ public virtual Task ValidateAsync( [FromQuery] string? branchFieldKey = null, [FromQuery] string? featuresFieldKey = null, [FromQuery] string? ministryFieldKey = null, - [FromQuery] string? programAreaFieldKey = null) + [FromQuery] string? programAreaFieldKey = null, + [FromQuery] string? displayNameFieldKey = null, + [FromQuery] string? divisionFieldKey = null) { if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->ValidateAsync: ModelState Invalid"); - return OnboardingRequestAppService.ValidateAsync(id, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + return OnboardingRequestAppService.ValidateAsync(id, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey, displayNameFieldKey, divisionFieldKey); } [HttpPost("{id}/create-tenant")] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml index f396127cdf..c58060cfc1 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml @@ -17,6 +17,24 @@ bool ContinueOnError { get; } + /// + /// Validated before runs. When false, the step is skipped (logged, + /// not treated as a failure) and the sequence moves on to the next step. Defaults to true - + /// override to check preconditions such as required configuration being present. + /// + Task CanExecuteAsync(Guid tenantId) => Task.FromResult(true); + Task ExecuteAsync(Guid tenantId); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs index 86ba5c63e4..20338724e1 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs @@ -34,4 +34,13 @@ public class CreateTenantInputDto public string? MinistryFieldKey { get; set; } public string? DivisionFieldKey { get; set; } public string? ProgramAreaFieldKey { get; set; } + + /// Comma-separated emails checked in the Metabase tab - sent to TenantCreateDto as-is. + public string? MetabaseUserEmails { get; set; } + + /// Comma-separated subset of newly-added Metabase emails to persist as the new Global default. + public string? MetabaseNewDefaultUserEmails { get; set; } + + /// Comma-separated default Metabase emails explicitly removed - deleted from the Global default. + public string? MetabaseRemovedDefaultUserEmails { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs index 6e44749adb..6c440ef31a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -10,6 +10,7 @@ using Unity.Flex.WorksheetInstances; using Unity.Modules.Shared.Correlation; using Unity.Modules.Shared.Permissions; +using Unity.TenantManagement.Metabase; using Unity.TenantManagement.Onboarding; using Unity.TenantManagement.Validation; using Volo.Abp; @@ -312,7 +313,8 @@ public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input Division = request.Division, Description = request.TenantDescription, UserIdentifier = userGuids[0], - FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null + FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null, + MetabaseUserEmails = input?.MetabaseUserEmails }); foreach (var userGuid in userGuids.Skip(1)) @@ -324,10 +326,29 @@ await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto }); } + if (!string.IsNullOrWhiteSpace(input?.MetabaseNewDefaultUserEmails) || !string.IsNullOrWhiteSpace(input?.MetabaseRemovedDefaultUserEmails)) + await UpdateMetabaseDefaultUserEmailsAsync(input.MetabaseNewDefaultUserEmails, input.MetabaseRemovedDefaultUserEmails); + if (ApplicationProvider != null) await ApplicationProvider.CloseApplicationAsync(id); } + private async Task UpdateMetabaseDefaultUserEmailsAsync(string? newEmailsCsv, string? removedEmailsCsv) + { + var removed = SplitEmails(removedEmailsCsv); + var updated = SplitEmails(await _settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails)) + .Concat(SplitEmails(newEmailsCsv)) + .Where(email => !removed.Contains(email, StringComparer.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + await _settingManager.SetGlobalAsync(MetabaseSettings.UserEmails, string.Join(",", updated)); + } + + private static List SplitEmails(string? emailsCsv) => + (emailsCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + private async Task> RunValidationStepsAsync(OnboardingRequestDto request) { var issues = new List(); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml index c58060cfc1..c0f0c0d763 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml @@ -15,6 +15,26 @@ + + +
+
+ + +
+

@L["CreateTenantModal:MetabaseDescription"]

+

@L["CreateTenantModal:MetabaseAccountNote"]

+ +
+ @foreach (var email in Model.DefaultMetabaseUserEmails) + { +
+ + + +
+ } +
+ +
+ + +
+
+ + +
+ + + + +
+ +
+
@L["CreateTenantModal:Validating"] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs index bf3d068f36..5ed6f9b8a8 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs @@ -1,14 +1,18 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Unity.Modules.Shared.Permissions; +using Unity.TenantManagement.Metabase; +using Volo.Abp.SettingManagement; namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; [Authorize(IdentityConsts.ITOperationsPolicyName)] -public class CreateTenantModalModel(IOnboardingRequestAppService onboardingRequestAppService) +public class CreateTenantModalModel(IOnboardingRequestAppService onboardingRequestAppService, ISettingManager settingManager) : OnboardingPageModel { [BindProperty(SupportsGet = true)] @@ -16,10 +20,18 @@ public class CreateTenantModalModel(IOnboardingRequestAppService onboardingReque public OnboardingRequestDto? OnboardingRequest { get; set; } + public List DefaultMetabaseUserEmails { get; set; } = []; + public virtual async Task OnGetAsync() { OnboardingRequest = await onboardingRequestAppService.GetAsync(Id); if (OnboardingRequest == null) return NotFound(); + + var defaultEmailsCsv = await settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails); + DefaultMetabaseUserEmails = (defaultEmailsCsv ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + return Page(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js index 42ac513786..613cb5b44b 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -400,11 +400,14 @@ const ministryFieldKey = $('#create-tenant-ministry-field').val() || null; const divisionFieldKey = $('#create-tenant-division-field').val() || null; const programAreaFieldKey = $('#create-tenant-program-area-field').val() || null; + const metabaseUserEmails = $('#create-tenant-metabase-user-emails').val() || null; + const metabaseNewDefaultUserEmails = $('#create-tenant-metabase-new-default-user-emails').val() || null; + const metabaseRemovedDefaultUserEmails = $('#create-tenant-metabase-removed-default-user-emails').val() || null; abp.ajax({ url: abp.appPath + 'api/onboarding-requests/' + applicationId + '/create-tenant', type: 'POST', - data: JSON.stringify({ tenantNameFieldKey, displayNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, divisionFieldKey, programAreaFieldKey }), + data: JSON.stringify({ tenantNameFieldKey, displayNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, divisionFieldKey, programAreaFieldKey, metabaseUserEmails, metabaseNewDefaultUserEmails, metabaseRemovedDefaultUserEmails }), contentType: 'application/json' }).done(function () { abp.notify.success(l('OnboardingModal:CreateSuccess')); @@ -491,6 +494,76 @@ }); } + // ─── Metabase tab: user list ─────────────────────────────────────────────── + + let _metabaseNewlyAddedEmails = []; + let _metabaseRemovedDefaultEmails = []; + + function _captureMetabaseUsersToForm() { + let checked = []; + $('#create-tenant-metabase-user-list .create-tenant-metabase-user-checkbox:checked').each(function () { + checked.push($(this).val()); + }); + $('#create-tenant-metabase-user-emails').val(checked.join(',')); + $('#create-tenant-metabase-removed-default-user-emails').val(_metabaseRemovedDefaultEmails.join(',')); + + if ($('#create-tenant-metabase-save-as-default').prop('checked')) { + let newDefaults = _metabaseNewlyAddedEmails.filter(function (email) { + return checked.indexOf(email) !== -1; + }); + $('#create-tenant-metabase-new-default-user-emails').val(newDefaults.join(',')); + } else { + $('#create-tenant-metabase-new-default-user-emails').val(''); + } + } + + function _addMetabaseUser(email) { + email = (email || '').trim(); + if (!email) return; + + let exists = $('#create-tenant-metabase-user-list .create-tenant-metabase-user-checkbox').toArray().some(function (el) { + return $(el).val().toLowerCase() === email.toLowerCase(); + }); + if (exists) { + abp.notify.warn(l('CreateTenantModal:MetabaseAlreadyInList')); + return; + } + + let id = 'create-tenant-metabase-user-' + $('#create-tenant-metabase-user-list .create-tenant-metabase-user-checkbox').length + '-' + Date.now(); + let $checkbox = $('') + .attr('id', id).val(email); + let $label = $('').attr('for', id).text(email); + $('
').append($checkbox).append($label).appendTo('#create-tenant-metabase-user-list'); + + _metabaseNewlyAddedEmails.push(email); + _captureMetabaseUsersToForm(); + } + + function _wireMetabaseTabHandlers() { + _metabaseNewlyAddedEmails = []; + _metabaseRemovedDefaultEmails = []; + _captureMetabaseUsersToForm(); + $('#create-tenant-metabase-user-list').on('change', '.create-tenant-metabase-user-checkbox', _captureMetabaseUsersToForm); + $('#create-tenant-metabase-save-as-default').on('change', _captureMetabaseUsersToForm); + $('#create-tenant-metabase-add-user-btn').on('click', function (e) { + e.preventDefault(); + _addMetabaseUser($('#create-tenant-metabase-new-user-email').val()); + $('#create-tenant-metabase-new-user-email').val(''); + }); + $('#create-tenant-metabase-new-user-email').on('keypress', function (e) { + if (e.which === 13) { + e.preventDefault(); + $('#create-tenant-metabase-add-user-btn').click(); + } + }); + $('#create-tenant-metabase-user-list').on('click', '.create-tenant-metabase-remove-default-btn', function (e) { + e.preventDefault(); + _metabaseRemovedDefaultEmails.push($(this).data('email')); + $(this).closest('.form-check').remove(); + _captureMetabaseUsersToForm(); + }); + } + abp.modals.createTenantModal = function () { return { initModal: function (publicApi, args) { @@ -502,6 +575,7 @@ } catch { _fieldValues = {}; } _loadCreateTenantFields(applicationId); + _wireMetabaseTabHandlers(); $('#btn-confirm-create-tenant').on('click', _onCreateTenantConfirm(applicationId)); } }; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml index 19049e0b29..47f190aa55 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml @@ -142,13 +142,15 @@

Users checked here will be granted access to this tenant's data in Metabase.

+

Note: a user must already have a Metabase account (via SSO login, or created under Admin > People) before they can be added to a group. Emails without an existing account are skipped during registration.

@foreach (var email in Model.DefaultMetabaseUserEmails) { -
+
- + +
}
@@ -164,6 +166,7 @@ +
} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs index 0fd4ac3ea0..aa59358719 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs @@ -57,22 +57,23 @@ public virtual async Task OnPostAsync() var input = ObjectMapper.Map(Tenant); await TenantAppService.CreateAsync(input); - if (!string.IsNullOrWhiteSpace(Tenant.MetabaseNewDefaultUserEmails)) + if (!string.IsNullOrWhiteSpace(Tenant.MetabaseNewDefaultUserEmails) || !string.IsNullOrWhiteSpace(Tenant.MetabaseRemovedDefaultUserEmails)) { - await SaveNewMetabaseDefaultUserEmailsAsync(Tenant.MetabaseNewDefaultUserEmails); + await UpdateMetabaseDefaultUserEmailsAsync(Tenant.MetabaseNewDefaultUserEmails, Tenant.MetabaseRemovedDefaultUserEmails); } return NoContent(); } - private async Task SaveNewMetabaseDefaultUserEmailsAsync(string newEmailsCsv) + private async Task UpdateMetabaseDefaultUserEmailsAsync(string? newEmailsCsv, string? removedEmailsCsv) { - var existing = SplitEmails(await SettingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails)); - var merged = existing + var removed = SplitEmails(removedEmailsCsv); + var updated = SplitEmails(await SettingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails)) .Concat(SplitEmails(newEmailsCsv)) + .Where(email => !removed.Contains(email, StringComparer.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase); - await SettingManager.SetGlobalAsync(MetabaseSettings.UserEmails, string.Join(",", merged)); + await SettingManager.SetGlobalAsync(MetabaseSettings.UserEmails, string.Join(",", updated)); } private static List SplitEmails(string? emailsCsv) => @@ -103,6 +104,9 @@ public class TenantInfoModel : ExtensibleObject /// Comma-separated subset of newly-added Metabase emails to persist as the new Global default. public string? MetabaseNewDefaultUserEmails { get; set; } + /// Comma-separated default Metabase emails explicitly removed - deleted from the Global default. + public string? MetabaseRemovedDefaultUserEmails { get; set; } + [Required] public string UserIdentifier { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js index c3ac2bb895..4076dc7d7b 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js @@ -232,6 +232,7 @@ $('#create-features-content').on('change', 'input[type="checkbox"]', _captureCreateFeaturesToForm); _metabaseNewlyAddedEmails = []; + _metabaseRemovedDefaultEmails = []; _captureMetabaseUsersToForm(); $('#metabase-user-list').on('change', '.metabase-user-checkbox', _captureMetabaseUsersToForm); $('#metabase-save-as-default').on('change', _captureMetabaseUsersToForm); @@ -246,6 +247,12 @@ $('#metabase-add-user-btn').click(); } }); + $('#metabase-user-list').on('click', '.metabase-remove-default-btn', function (e) { + e.preventDefault(); + _metabaseRemovedDefaultEmails.push($(this).data('email')); + $(this).closest('.form-check').remove(); + _captureMetabaseUsersToForm(); + }); $('#create-pane-features').closest('form').on('invalid-form.validate', function (e, validator) { if (validator.errorList.length > 0) { @@ -260,6 +267,7 @@ // ─── Metabase tab: user list ─────────────────────────────────────────────── let _metabaseNewlyAddedEmails = []; + let _metabaseRemovedDefaultEmails = []; function _captureMetabaseUsersToForm() { let checked = []; @@ -267,6 +275,7 @@ checked.push($(this).val()); }); $('#metabase-user-emails').val(checked.join(',')); + $('#metabase-removed-default-user-emails').val(_metabaseRemovedDefaultEmails.join(',')); if ($('#metabase-save-as-default').prop('checked')) { let newDefaults = _metabaseNewlyAddedEmails.filter(function (email) { diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs index 7667f51dfa..cce0b7c74c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs @@ -8,6 +8,7 @@ using Unity.Flex.Worksheets; using Unity.Flex.Worksheets.Values; using Unity.Flex.WorksheetInstances; +using Unity.TenantManagement.Metabase; using Unity.TenantManagement.Onboarding; using Volo.Abp; using Volo.Abp.Application.Dtos; @@ -469,4 +470,87 @@ await _tenantAppService.Received(1).AssignManagerAsync(Arg.Is>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Metabase Co"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "Metabase Co" }); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + MetabaseUserEmails = "a@gov.bc.ca,b@gov.bc.ca" + }); + + await _tenantAppService.Received(1).CreateAsync(Arg.Is(d => + d.MetabaseUserEmails == "a@gov.bc.ca,b@gov.bc.ca")); + } + + [Fact] + public async Task CreateTenantAsync_MetabaseNewDefaultUserEmailsProvided_MergesIntoGlobalSetting() + { + var id = Guid.NewGuid(); + var newTenantId = Guid.NewGuid(); + + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Metabase Defaults Co"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "Metabase Defaults Co" }); + _settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails) + .Returns("existing@gov.bc.ca"); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + MetabaseUserEmails = "existing@gov.bc.ca,new@gov.bc.ca", + MetabaseNewDefaultUserEmails = "new@gov.bc.ca" + }); + + await _settingManager.Received(1).SetGlobalAsync( + MetabaseSettings.UserEmails, "existing@gov.bc.ca,new@gov.bc.ca"); + } + + [Fact] + public async Task CreateTenantAsync_MetabaseRemovedDefaultUserEmailsProvided_RemovesFromGlobalSetting() + { + var id = Guid.NewGuid(); + var newTenantId = Guid.NewGuid(); + + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Metabase Removal Co"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "Metabase Removal Co" }); + _settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails) + .Returns("keep@gov.bc.ca,stale@gov.bc.ca"); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + MetabaseUserEmails = "keep@gov.bc.ca", + MetabaseRemovedDefaultUserEmails = "stale@gov.bc.ca" + }); + + await _settingManager.Received(1).SetGlobalAsync(MetabaseSettings.UserEmails, "keep@gov.bc.ca"); + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs index 89507ea589..aa1d760237 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/IMetabaseApiClient.cs @@ -11,7 +11,7 @@ namespace Unity.GrantManager.Integrations.Metabase; /// public interface IMetabaseApiClient { - Task CreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, CancellationToken cancellationToken = default); + Task CreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, bool ssl, CancellationToken cancellationToken = default); Task SyncDatabaseSchemaAsync(int databaseId, CancellationToken cancellationToken = default); Task RescanDatabaseValuesAsync(int databaseId, CancellationToken cancellationToken = default); Task CreateGroupAsync(string name, CancellationToken cancellationToken = default); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs index 27ed590c3a..4b8fae41ac 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs @@ -18,14 +18,14 @@ public class MetabaseApiClient( { private const string ApiKeyHeader = "x-api-key"; - public async Task CreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, CancellationToken cancellationToken = default) + public async Task CreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, bool ssl, CancellationToken cancellationToken = default) { var body = new { engine = "postgres", name, is_full_sync = true, - details = new { host, port, dbname = dbName, user = username, password, ssl = true } + details = new { host, port, dbname = dbName, user = username, password, ssl } }; var result = await PostAsync("/api/database", body, cancellationToken); return result.Value("id"); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs index 0a3f62c106..487528d539 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseOptions.cs @@ -4,4 +4,28 @@ public class MetabaseOptions { /// Admin API key - same key the Metabase admin UI/PowerShell runbook uses (x-api-key header). public string ApiKey { get; set; } = string.Empty; + + /// + /// Local-dev-only override for the Postgres host passed to Metabase when registering a + /// tenant's database connection. Tenant readonly connection strings store "localhost" as the + /// host (correct for the .NET app, which runs on the host machine) - but a dockerized local + /// Metabase container can't reach "localhost" that way, since that resolves to the container + /// itself. Set this to whatever hostname your local Metabase container can actually reach + /// Postgres by - e.g. the Postgres container's name/service (like "unitydb") if both containers + /// share a Docker network, or "host.docker.internal" if Metabase needs to reach out to the host + /// machine instead (which may also require a Windows Firewall inbound allow rule for 5432, and + /// doesn't resolve for every local Docker setup - verify with a direct call to Metabase's + /// POST /api/database before assuming it's this setting). Leave unset in deployed environments - + /// there both the app and Metabase reach Postgres via its OpenShift service name, so the stored + /// host is already correct. + /// + public string DbHostOverride { get; set; } = string.Empty; + + /// + /// Local-dev-only override for whether Metabase connects to the tenant's Postgres database + /// over SSL. Deployed Postgres (Crunchy on OpenShift) requires SSL, so the default (null, no + /// override) sends ssl: true. A plain local `postgres` Docker image has SSL disabled + /// out of the box, so set this to false locally or Metabase's connection attempt fails. + /// + public bool? DbSslOverride { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs index 1134170d01..d0e68fdcb6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs @@ -41,11 +41,20 @@ public override async Task ExecuteAsync(PostTenantCreationStepArgs args) { using (currentTenant.Change(args.TenantId)) { - logger.LogInformation( - "{Prefix} Running step {StepIndex} '{StepName}' for tenant {TenantId}", - LogPrefix, args.StepIndex, step.StepName, args.TenantId); + if (!await step.CanExecuteAsync(args.TenantId)) + { + logger.LogInformation( + "{Prefix} Skipping step {StepIndex} '{StepName}' for tenant {TenantId} - CanExecuteAsync returned false", + LogPrefix, args.StepIndex, step.StepName, args.TenantId); + } + else + { + logger.LogInformation( + "{Prefix} Running step {StepIndex} '{StepName}' for tenant {TenantId}", + LogPrefix, args.StepIndex, step.StepName, args.TenantId); - await step.ExecuteAsync(args.TenantId); + await step.ExecuteAsync(args.TenantId); + } } } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs index b971624f64..bf45bb8d60 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs @@ -4,9 +4,11 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Unity.GrantManager.Integrations.Metabase; using Unity.Modules.Shared.PostTenantCreation; using Unity.TenantManagement.Metabase; +using Volo.Abp; using Volo.Abp.DependencyInjection; using Volo.Abp.Security.Encryption; using Volo.Abp.Settings; @@ -40,11 +42,14 @@ namespace Unity.GrantManager.Tenants.PostCreation.Steps; /// is true - a Metabase outage is logged but doesn't block tenant /// creation or later post-creation steps. /// +[RemoteService(false)] +[ExposeServices(typeof(IPostTenantCreationStep))] public class MetabaseTenantRegistrationStep( IMetabaseApiClient metabaseApiClient, ITenantRepository tenantRepository, IStringEncryptionService stringEncryptionService, ISettingManager settingManager, + IOptions metabaseOptions, ILogger logger) : IPostTenantCreationStep, ITransientDependency { @@ -58,6 +63,19 @@ public class MetabaseTenantRegistrationStep( // A Metabase outage shouldn't block other post-creation steps from running. public bool ContinueOnError => true; + public virtual Task CanExecuteAsync(Guid tenantId) + { + if (string.IsNullOrWhiteSpace(metabaseOptions.Value.ApiKey)) + { + logger.LogInformation( + "{Prefix} No Metabase API key configured - skipping registration for tenant {TenantId}.", + LogPrefix, tenantId); + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + public virtual async Task ExecuteAsync(Guid tenantId) { var tenant = await tenantRepository.GetAsync(tenantId, includeDetails: true); @@ -74,7 +92,17 @@ public virtual async Task ExecuteAsync(Guid tenantId) var (host, port, dbName, username, password) = ParseConnectionString(stringEncryptionService.Decrypt(encryptedReadOnlyConnectionString)); - var databaseId = await metabaseApiClient.CreateDatabaseAsync(tenant.Name, host, port, dbName, username, password); + if (!string.IsNullOrWhiteSpace(metabaseOptions.Value.DbHostOverride)) + { + logger.LogInformation( + "{Prefix} Overriding Postgres host '{OriginalHost}' with '{OverrideHost}' for tenant {TenantId} (Metabase:DbHostOverride is set).", + LogPrefix, host, metabaseOptions.Value.DbHostOverride, tenantId); + host = metabaseOptions.Value.DbHostOverride; + } + + var ssl = metabaseOptions.Value.DbSslOverride ?? true; + + var databaseId = await metabaseApiClient.CreateDatabaseAsync(tenant.Name, host, port, dbName, username, password, ssl); await metabaseApiClient.SyncDatabaseSchemaAsync(databaseId); await metabaseApiClient.RescanDatabaseValuesAsync(databaseId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 4b32807332..edd337fc6e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -631,6 +631,14 @@ "CreateTenantModal:CreatingWarningBody": "Tenant provisioning is in progress — please do not close this window or navigate away until the process completes.", "CreateTenantModal:ConfirmButton": "Create Tenant", "CreateTenantModal:NoFieldsWarning": "No worksheet data is available for this request. Tenant creation requires at least one submitted worksheet with field data.", + "CreateTenantModal:DetailsTab": "Details", + "CreateTenantModal:MetabaseTab": "Metabase", + "CreateTenantModal:MetabaseDescription": "Users checked here will be granted access to this tenant's data in Metabase.", + "CreateTenantModal:MetabaseAccountNote": "Note: a user must already have a Metabase account (via SSO login, or created under Admin > People) before they can be added to a group. Emails without an existing account are skipped during registration.", + "CreateTenantModal:MetabaseAddButton": "Add", + "CreateTenantModal:MetabaseSaveAsDefaultLabel": "Save newly added users as default for future tenants", + "CreateTenantModal:MetabaseRemoveDefaultTitle": "Remove from default list", + "CreateTenantModal:MetabaseAlreadyInList": "That user is already in the list.", "TenantList:ActionsButton": "Actions", "TenantList:ConfigurationAction": "Configuration", "TenantList:LicencePlate": "Licence Plate", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index f317444a18..c80ec41d0d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -135,11 +135,19 @@ "ReportingAI": { "JWTSecret": "" }, - "Azure": { "OpenAI": { "ApiKey": "", "Endpoint": "" } + }, + "TenantCreation": { + "Steps": { + "Metabase": { + "ApiKey": "", + "DbHostOverride": "", + "DbSslOverride": false + } + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index 8a7fba49f6..0919e5791c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -39,13 +39,6 @@ "ChesClientId": "", "ChesClientSecret": "" }, - "TenantCreation": { - "Steps": { - "Metabase": { - "ApiKey": "" - } - } - }, "Intake": { "FormId": "", "ApiKey": "", @@ -169,5 +162,14 @@ "Endpoint": "" }, "UNITY_GITHUB_PAT": "" + }, + "TenantCreation": { + "Steps": { + "Metabase": { + "ApiKey": "", + "DbHostOverride": "", + "DbSslOverride": false + } + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs index f7025a34d4..a13e112e85 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/PostTenantCreationSequenceJobTests.cs @@ -13,7 +13,7 @@ namespace Unity.GrantManager.Tenants.PostCreation; public class PostTenantCreationSequenceJobTests { - private sealed class FakeStep(int order, string name, bool continueOnError, Func? onExecute = null) + private sealed class FakeStep(int order, string name, bool continueOnError, Func? onExecute = null, bool canExecute = true) : IPostTenantCreationStep { public int Order { get; } = order; @@ -21,6 +21,8 @@ private sealed class FakeStep(int order, string name, bool continueOnError, Func public bool ContinueOnError { get; } = continueOnError; public bool Executed { get; private set; } + public Task CanExecuteAsync(Guid tenantId) => Task.FromResult(canExecute); + public async Task ExecuteAsync(Guid tenantId) { Executed = true; @@ -106,6 +108,20 @@ public async Task ExecuteAsync_StepThrows_ContinueOnErrorFalse_StopsSequence() enqueued.ShouldBeEmpty(); } + [Fact] + public async Task ExecuteAsync_CanExecuteAsyncReturnsFalse_SkipsExecuteButStillEnqueuesNextStep() + { + var step0 = new FakeStep(0, "Step0", continueOnError: false, canExecute: false); + var step1 = new FakeStep(1, "Step1", continueOnError: false); + var (job, enqueued) = CreateJob([step0, step1]); + + await job.ExecuteAsync(new PostTenantCreationStepArgs { TenantId = Guid.NewGuid(), StepIndex = 0 }); + + step0.Executed.ShouldBeFalse(); + var next = enqueued.ShouldHaveSingleItem(); + next.StepIndex.ShouldBe(1); + } + [Fact] public async Task ExecuteAsync_RunsSteps_InAscendingOrder_RegardlessOfRegistrationOrder() { diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs index d57258797b..f5efbee7f2 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStepTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using Shouldly; using Unity.GrantManager.Integrations.Metabase; @@ -32,7 +33,7 @@ private static Tenant CreateTenant(string name) } private static (MetabaseTenantRegistrationStep Step, IMetabaseApiClient MetabaseApiClient, ISettingManager SettingManager, Tenant Tenant) - CreateStep(string? encryptedReadOnlyConnectionString = "encrypted-blob") + CreateStep(string? encryptedReadOnlyConnectionString = "encrypted-blob", string? apiKey = "test-api-key", string? dbHostOverride = null, bool? dbSslOverride = null) { var tenant = CreateTenant("AG-MARB"); if (encryptedReadOnlyConnectionString != null) @@ -48,9 +49,15 @@ private static (MetabaseTenantRegistrationStep Step, IMetabaseApiClient Metabase var settingManager = Substitute.For(); var metabaseApiClient = Substitute.For(); + var metabaseOptions = Options.Create(new MetabaseOptions + { + ApiKey = apiKey ?? string.Empty, + DbHostOverride = dbHostOverride ?? string.Empty, + DbSslOverride = dbSslOverride + }); var step = new MetabaseTenantRegistrationStep( - metabaseApiClient, tenantRepository, encryptionService, settingManager, + metabaseApiClient, tenantRepository, encryptionService, settingManager, metabaseOptions, Substitute.For>()); return (step, metabaseApiClient, settingManager, tenant); @@ -64,6 +71,25 @@ public void ContinueOnError_IsTrue_SoAMetabaseOutageDoesNotBlockLaterSteps() step.ContinueOnError.ShouldBeTrue(); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CanExecuteAsync_NoApiKeyConfigured_ReturnsFalse(string? apiKey) + { + var (step, _, _, tenant) = CreateStep(apiKey: apiKey); + + (await step.CanExecuteAsync(tenant.Id)).ShouldBeFalse(); + } + + [Fact] + public async Task CanExecuteAsync_ApiKeyConfigured_ReturnsTrue() + { + var (step, _, _, tenant) = CreateStep(); + + (await step.CanExecuteAsync(tenant.Id)).ShouldBeTrue(); + } + [Fact] public async Task ExecuteAsync_NoReadonlyConnectionString_SkipsWithoutCallingMetabase() { @@ -71,7 +97,7 @@ public async Task ExecuteAsync_NoReadonlyConnectionString_SkipsWithoutCallingMet await step.ExecuteAsync(tenant.Id); - await metabaseApiClient.DidNotReceiveWithAnyArgs().CreateDatabaseAsync(default!, default!, default, default!, default!, default!); + await metabaseApiClient.DidNotReceiveWithAnyArgs().CreateDatabaseAsync(default!, default!, default, default!, default!, default!, default); } [Fact] @@ -83,7 +109,7 @@ public async Task ExecuteAsync_CreatesDatabaseGroupAndCollection_UsingParsedConn settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails).Returns("user1@gov.bc.ca,user2@gov.bc.ca"); metabaseApiClient.CreateDatabaseAsync( - tenant.Name, "dev-crunchy-postgres-primary.ce395f-dev.svc", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t") + tenant.Name, "dev-crunchy-postgres-primary.ce395f-dev.svc", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t", true) .Returns(11); metabaseApiClient.CreateGroupAsync(tenant.Name).Returns(22); metabaseApiClient.FindUserIdByEmailAsync("user1@gov.bc.ca").Returns(101); @@ -100,6 +126,38 @@ public async Task ExecuteAsync_CreatesDatabaseGroupAndCollection_UsingParsedConn await metabaseApiClient.Received(1).GrantGroupCollectionAccessAsync(22, 33); } + [Fact] + public async Task ExecuteAsync_DbHostOverrideConfigured_UsesOverrideHostInsteadOfConnectionStringHost() + { + var (step, metabaseApiClient, settingManager, tenant) = CreateStep(dbHostOverride: "host.docker.internal"); + settingManager.GetOrNullAsync(MetabaseSettings.UserEmails, TenantSettingValueProvider.ProviderName, tenant.Id.ToString()) + .Returns((string?)null); + settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails).Returns((string?)null); + metabaseApiClient.CreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.CreateCollectionAsync(tenant.Name).Returns(33); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).CreateDatabaseAsync( + tenant.Name, "host.docker.internal", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t", true); + } + + [Fact] + public async Task ExecuteAsync_DbSslOverrideFalse_DisablesSslForDatabaseConnection() + { + var (step, metabaseApiClient, settingManager, tenant) = CreateStep(dbSslOverride: false); + settingManager.GetOrNullAsync(MetabaseSettings.UserEmails, TenantSettingValueProvider.ProviderName, tenant.Id.ToString()) + .Returns((string?)null); + settingManager.GetOrNullGlobalAsync(MetabaseSettings.UserEmails).Returns((string?)null); + metabaseApiClient.CreateGroupAsync(tenant.Name).Returns(22); + metabaseApiClient.CreateCollectionAsync(tenant.Name).Returns(33); + + await step.ExecuteAsync(tenant.Id); + + await metabaseApiClient.Received(1).CreateDatabaseAsync( + tenant.Name, "dev-crunchy-postgres-primary.ce395f-dev.svc", 5432, "T_ABC123", "t_abc123_readonly", "s3cr3t", false); + } + [Fact] public async Task ExecuteAsync_TenantScopedUserEmailsSetting_TakesPrecedenceOverGlobalDefault() { From 0a80aec6cd2c0c7c9cd8a86452fcf9c409b3af1e Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 21 Aug 2026 14:04:57 -0700 Subject: [PATCH 3/5] AB#34137 PR updates --- .../Pages/TenantManagement/Onboarding/Index.js | 2 +- .../Pages/TenantManagement/Tenants/Index.js | 17 ++++++++++++----- .../Steps/MetabaseTenantRegistrationStep.cs | 13 +++++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js index 613cb5b44b..3d97ca663c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -509,7 +509,7 @@ if ($('#create-tenant-metabase-save-as-default').prop('checked')) { let newDefaults = _metabaseNewlyAddedEmails.filter(function (email) { - return checked.indexOf(email) !== -1; + return checked.includes(email); }); $('#create-tenant-metabase-new-default-user-emails').val(newDefaults.join(',')); } else { diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js index 4076dc7d7b..1344561c3a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js @@ -279,7 +279,7 @@ if ($('#metabase-save-as-default').prop('checked')) { let newDefaults = _metabaseNewlyAddedEmails.filter(function (email) { - return checked.indexOf(email) !== -1; + return checked.includes(email); }); $('#metabase-new-default-user-emails').val(newDefaults.join(',')); } else { @@ -320,10 +320,17 @@ function _generateGuid() { if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - let r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + + // Fallback for environments without crypto.randomUUID - still CSPRNG-backed via + // crypto.getRandomValues, not Math.random(), since this key is used as a cache-busting + // provider key sent to the server, not truly security-sensitive, but there's no reason + // to reach for a weaker PRNG when getRandomValues is universally available. + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, function (b) { return b.toString(16).padStart(2, '0'); }).join(''); + return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + hex.slice(16, 20) + '-' + hex.slice(20); } function _renderFeatureItem(feature) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs index bf45bb8d60..a3609f6c7c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs @@ -114,8 +114,8 @@ public virtual async Task ExecuteAsync(Guid tenantId) if (userId == null) { logger.LogWarning( - "{Prefix} User '{Email}' not found in Metabase for tenant {TenantId} - they must log in via LDAP or be created under Admin > People before they can be added to a group.", - LogPrefix, email, tenantId); + "{Prefix} User '{MaskedEmail}' not found in Metabase for tenant {TenantId} - they must log in via LDAP or be created under Admin > People before they can be added to a group.", + LogPrefix, MaskEmail(email), tenantId); continue; } await metabaseApiClient.AddGroupMemberAsync(groupId, userId.Value); @@ -142,6 +142,15 @@ private async Task> GetUserEmailsAsync(Guid tenantId) .ToList(); } + // Avoids writing a user's full email address to logs (CodeQL: exposure of private + // information) while keeping enough of it for an admin to correlate a "not found" warning + // with a known user. + private static string MaskEmail(string email) + { + var atIndex = email.IndexOf('@', StringComparison.Ordinal); + return atIndex <= 1 ? "***" : string.Concat(email.AsSpan(0, 1), "***", email.AsSpan(atIndex)); + } + private static (string Host, int Port, string DbName, string Username, string Password) ParseConnectionString(string connectionString) { string? Get(string key) From 9c7078e2262aac7cfd5da14f81902c31333ff7ba Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 21 Aug 2026 14:52:04 -0700 Subject: [PATCH 4/5] AB#34137 more codeQL suggestions --- .../Handlers/TenantCreatedEventHandler.cs | 8 +- .../Metabase/MetabaseApiClient.cs | 81 ++++++++---- .../Unity.GrantManager.Web/appsettings.json | 3 +- .../Metabase/MetabaseApiClientTests.cs | 125 ++++++++++++++++++ 4 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs index 5b69356174..670bfe8302 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Handlers/TenantCreatedEventHandler.cs @@ -76,11 +76,15 @@ await _backgroundJobManager.EnqueueAsync(new PostTenantCreationStepArgs // Global default changes in the meantime. private async Task SaveMetabaseUserEmailsAsync(TenantCreatedEto eto, Guid tenantId) { - if (!eto.Properties.TryGetValue("MetabaseUserEmails", out var emailsRaw) || string.IsNullOrWhiteSpace(emailsRaw)) + // An empty string is a deliberate "no Metabase users for this tenant" choice - it must + // still be persisted (not skipped), otherwise MetabaseTenantRegistrationStep's + // GetUserEmailsAsync falls back to the Global default list and grants users the + // tenant creator explicitly unchecked. + if (!eto.Properties.TryGetValue("MetabaseUserEmails", out var emailsRaw)) return; await _settingManager.SetAsync( - MetabaseSettings.UserEmails, emailsRaw, TenantSettingValueProvider.ProviderName, tenantId.ToString()); + MetabaseSettings.UserEmails, emailsRaw ?? string.Empty, TenantSettingValueProvider.ProviderName, tenantId.ToString()); } private async Task EnableRequestedFeaturesAsync(TenantCreatedEto eto, Guid tenantId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs index 4b8fae41ac..d315a138ab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Metabase/MetabaseApiClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -18,6 +19,12 @@ public class MetabaseApiClient( { private const string ApiKeyHeader = "x-api-key"; + // Metabase's permissions/collection graph endpoints use an optimistic-concurrency "revision" + // number - a PUT with a stale revision (because another tenant registration updated the graph + // first) is rejected. Retry the whole read-mutate-write cycle against a freshly-fetched graph + // rather than surfacing a transient conflict as a permanent failure. + private const int MaxGraphUpdateAttempts = 3; + public async Task CreateDatabaseAsync(string name, string host, int port, string dbName, string username, string password, bool ssl, CancellationToken cancellationToken = default) { var body = new @@ -54,23 +61,19 @@ public async Task CreateGroupAsync(string name, CancellationToken cancellat public Task AddGroupMemberAsync(int groupId, int userId, CancellationToken cancellationToken = default) => PostAsync("/api/permissions/membership", new { group_id = groupId, user_id = userId }, cancellationToken); - public async Task GrantGroupDatabaseAccessAsync(int groupId, int databaseId, CancellationToken cancellationToken = default) - { - var graph = await GetAsync("/api/permissions/graph", cancellationToken); - var groups = (JObject?)graph["groups"] ?? new JObject(); - var groupKey = groupId.ToString(); - var groupNode = (JObject?)groups[groupKey] ?? new JObject(); - - groupNode[databaseId.ToString()] = new JObject + public Task GrantGroupDatabaseAccessAsync(int groupId, int databaseId, CancellationToken cancellationToken = default) => + UpdateGraphWithRetryAsync("/api/permissions/graph", groups => { - ["view-data"] = "unrestricted", - ["create-queries"] = "query-builder-and-native" - }; - groups[groupKey] = groupNode; + var groupKey = groupId.ToString(); + var groupNode = (JObject?)groups[groupKey] ?? new JObject(); - await PutAsync("/api/permissions/graph", - new { groups, revision = graph.Value("revision") }, cancellationToken); - } + groupNode[databaseId.ToString()] = new JObject + { + ["view-data"] = "unrestricted", + ["create-queries"] = "query-builder-and-native" + }; + groups[groupKey] = groupNode; + }, cancellationToken); public async Task CreateCollectionAsync(string name, CancellationToken cancellationToken = default) { @@ -78,18 +81,41 @@ public async Task CreateCollectionAsync(string name, CancellationToken canc return result.Value("id"); } - public async Task GrantGroupCollectionAccessAsync(int groupId, int collectionId, CancellationToken cancellationToken = default) - { - var graph = await GetAsync("/api/collection/graph", cancellationToken); - var groups = (JObject?)graph["groups"] ?? new JObject(); - var groupKey = groupId.ToString(); - var groupNode = (JObject?)groups[groupKey] ?? new JObject(); + public Task GrantGroupCollectionAccessAsync(int groupId, int collectionId, CancellationToken cancellationToken = default) => + UpdateGraphWithRetryAsync("/api/collection/graph", groups => + { + var groupKey = groupId.ToString(); + var groupNode = (JObject?)groups[groupKey] ?? new JObject(); - groupNode[collectionId.ToString()] = "write"; - groups[groupKey] = groupNode; + groupNode[collectionId.ToString()] = "write"; + groups[groupKey] = groupNode; + }, cancellationToken); - await PutAsync("/api/collection/graph", - new { groups, revision = graph.Value("revision") }, cancellationToken); + private async Task UpdateGraphWithRetryAsync(string graphPath, Action applyMutation, CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + var graph = await GetAsync(graphPath, cancellationToken); + var groups = (JObject?)graph["groups"] ?? new JObject(); + applyMutation(groups); + + var response = await PutRawAsync(graphPath, + new { groups, revision = graph.Value("revision") }, cancellationToken); + + if (response.IsSuccessStatusCode) + return; + + var isRevisionConflict = response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.BadRequest; + if (!isRevisionConflict || attempt >= MaxGraphUpdateAttempts) + { + var content = response.Content == null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken); + throw new IntegrationServiceException( + $"Metabase API call to '{graphPath}' failed with status {response.StatusCode}: {content}"); + } + + // Stale revision - another concurrent tenant registration updated the graph first. + // Loop around to re-fetch the latest graph and reapply this mutation on top of it. + } } private async Task GetBaseUrlAsync() => @@ -114,12 +140,11 @@ private async Task PostAsync(string path, object body, CancellationToke return await ReadJsonAsync(response, path); } - private async Task PutAsync(string path, object body, CancellationToken cancellationToken) + private async Task PutRawAsync(string path, object body, CancellationToken cancellationToken) { var baseUrl = await GetBaseUrlAsync(); - var response = await resilientHttpRequest.HttpAsync( + return await resilientHttpRequest.HttpAsync( HttpMethod.Put, $"{baseUrl}{path}", body, extraHeaders: BuildHeaders(), cancellationToken: cancellationToken); - return await ReadJsonAsync(response, path); } private static async Task ReadJsonAsync(HttpResponseMessage response, string path) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index 0919e5791c..91383e7d3e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -167,8 +167,7 @@ "Steps": { "Metabase": { "ApiKey": "", - "DbHostOverride": "", - "DbSslOverride": false + "DbHostOverride": "" } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs new file mode 100644 index 0000000000..ad2589ef05 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Integrations/Metabase/MetabaseApiClientTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Integrations.Exceptions; +using Unity.Modules.Shared.Http; +using Xunit; + +namespace Unity.GrantManager.Integrations.Metabase; + +public class MetabaseApiClientTests +{ + private const string BaseUrl = "https://metabase.example"; + + private static (MetabaseApiClient Client, IResilientHttpRequest Http) CreateClient() + { + var http = Substitute.For(); + var endpointService = Substitute.For(); + endpointService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.METABASE_API_BASE).Returns(BaseUrl); + var options = Options.Create(new MetabaseOptions { ApiKey = "test-api-key" }); + + return (new MetabaseApiClient(http, endpointService, options), http); + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + private static void SetupHttpSequence(IResilientHttpRequest http, params HttpResponseMessage[] responses) => + http.HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()) + .Returns(responses[0], responses[1..]); + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_NoConflict_SucceedsOnFirstAttempt() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.OK, "{}")); + + await client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11); + + await http.Received(2).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_StaleRevisionOnFirstPut_RefetchesGraphAndRetries() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), // GET #1 + JsonResponse(HttpStatusCode.Conflict, "{\"message\":\"stale\"}"), // PUT #1 - stale revision + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":2}"), // GET #2 (retry re-fetch) + JsonResponse(HttpStatusCode.OK, "{}")); // PUT #2 - succeeds + + await Should.NotThrowAsync(() => client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11)); + + await http.Received(4).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_ConflictOnEveryAttempt_ThrowsAfterMaxAttempts() + { + var (client, http) = CreateClient(); + // 3 attempts allowed: GET/PUT-conflict x3 (6 calls total), all conflicting. + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.Conflict, "{}"), + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":2}"), + JsonResponse(HttpStatusCode.Conflict, "{}"), + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":3}"), + JsonResponse(HttpStatusCode.Conflict, "{}")); + + await Should.ThrowAsync( + () => client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11)); + + await http.Received(6).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupDatabaseAccessAsync_NonConflictFailure_ThrowsWithoutRetrying() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.InternalServerError, "{}")); + + await Should.ThrowAsync( + () => client.GrantGroupDatabaseAccessAsync(groupId: 5, databaseId: 11)); + + // Only the initial GET + PUT - a non-conflict failure isn't retried at this layer. + await http.Received(2).HttpAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any<(string username, string password)?>(), Arg.Any(), + Arg.Any?>(), Arg.Any()); + } + + [Fact] + public async Task GrantGroupCollectionAccessAsync_StaleRevisionOnFirstPut_RefetchesGraphAndRetries() + { + var (client, http) = CreateClient(); + SetupHttpSequence(http, + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":1}"), + JsonResponse(HttpStatusCode.BadRequest, "{}"), + JsonResponse(HttpStatusCode.OK, "{\"groups\":{},\"revision\":2}"), + JsonResponse(HttpStatusCode.OK, "{}")); + + await Should.NotThrowAsync(() => client.GrantGroupCollectionAccessAsync(groupId: 5, collectionId: 22)); + } +} From c4b9ffdbb81c16385bbe7aa3296b714af3ff6648 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 21 Aug 2026 15:32:40 -0700 Subject: [PATCH 5/5] AB#34137 more codeQL feedback --- .../TenantAppService.cs | 12 ++++++--- .../Tenants/ConfigurationModal.cshtml | 1 + .../Tenants/ConfigurationModal.cshtml.cs | 1 + .../Tenants/CreateModal.cshtml.cs | 16 +++++++++++ .../TenantManagement/Tenants/EditModal.cshtml | 1 + .../Tenants/EditModal.cshtml.cs | 1 + ...UnityTenantManagementWebMapperlyProfile.cs | 4 +++ .../TenantAppService_Tests.cs | 27 +++++++++++++++++++ .../PostTenantCreationSequenceJob.cs | 13 ++++++--- .../Steps/MetabaseTenantRegistrationStep.cs | 21 ++++++++------- .../Integrations/DynamicUrlDataSeeder.cs | 14 +++++++++- 11 files changed, 93 insertions(+), 18 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index d2dc87ebc4..bb645051b7 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -60,7 +60,7 @@ public virtual async Task> GetListAsync(GetTenantsInpu var extraPropertySortFields = new HashSet(StringComparer.OrdinalIgnoreCase) { - ExtraPropDivision, ExtraPropBranch, ExtraPropDescription, ExtraPropCasClientCode + ExtraPropDisplayName, ExtraPropDivision, ExtraPropBranch, ExtraPropDescription, ExtraPropCasClientCode }; var dbSortFields = new HashSet(StringComparer.OrdinalIgnoreCase) @@ -91,10 +91,13 @@ public virtual async Task> GetListAsync(GetTenantsInpu ); } - // In-memory path: needed when filtering on ExtraProperties or sorting on ExtraProperties - // Keep native name filtering in SQL and only layer ExtraProperties matching on top. + // In-memory path: needed when filtering on ExtraProperties or sorting on ExtraProperties. + // Fetch unfiltered here - the underlying repository's filter only matches against Name, so + // passing it through would exclude an ExtraProperty-only match (e.g. filtering by Division + // text that isn't also in the tenant's Name) before the OR-based filter below ever runs. + // Name + ExtraProperties matching is applied together, in memory, further down. var dbSorting = dbSortFields.Contains(sortField) ? input.Sorting : nameof(Tenant.Name); - var filteredTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, input.Filter); + var filteredTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, filter: null); IEnumerable result = filteredTenants; @@ -104,6 +107,7 @@ public virtual async Task> GetListAsync(GetTenantsInpu var filter = input.Filter.Trim(); result = result.Where(t => (t.Name != null && t.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) || + MatchesExtraProperty(t, ExtraPropDisplayName, filter) || MatchesExtraProperty(t, ExtraPropDivision, filter) || MatchesExtraProperty(t, ExtraPropBranch, filter) || MatchesExtraProperty(t, ExtraPropDescription, filter) || diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml index 8cea4764ca..11441b601f 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml @@ -65,6 +65,7 @@
+ diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs index 6966884120..1ce3203b66 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs @@ -121,6 +121,7 @@ public class TenantInfoModel : ExtensibleObject, IHasConcurrencyStamp [Display(Name = "DisplayName:TenantName")] public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs index aa59358719..85e09d9058 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/CreateModal.cshtml.cs @@ -54,6 +54,22 @@ public virtual async Task OnPostAsync() { ValidateModel(); + // The Features/Metabase tabs are only hidden client-side for non-IT-Admin/Ops callers - + // TenantAppService.CreateAsync itself is reachable by anyone with plain Tenants.Create + // permission (TenantsCreateOrITOps), so a forged POST could otherwise set arbitrary + // FeatureKeys/MetabaseUserEmails. Re-check the same policy server-side and strip these + // privileged fields when it fails, mirroring ConfigurationModalModel's FeaturesJson guard. + var canManageFeatures = (await AuthorizationService + .AuthorizeAsync(User, IdentityConsts.ITAdminOrITOperationsPolicyName)).Succeeded; + + if (!canManageFeatures) + { + Tenant.FeatureKeys = null; + Tenant.MetabaseUserEmails = null; + Tenant.MetabaseNewDefaultUserEmails = null; + Tenant.MetabaseRemovedDefaultUserEmails = null; + } + var input = ObjectMapper.Map(Tenant); await TenantAppService.CreateAsync(input); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml index 1421d24d89..22926d0357 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml @@ -20,6 +20,7 @@ + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs index 703aadd6fe..55773d9503 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml.cs @@ -49,6 +49,7 @@ public class TenantInfoModel : ExtensibleObject, IHasConcurrencyStamp [DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))] [Display(Name = "DisplayName:TenantName")] public string Name { get; set; } + public string DisplayName { get; set; } = string.Empty; public string Division { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs index 020c1f0bc0..c09d7676fa 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs @@ -29,6 +29,7 @@ public override void Map(TenantDto source, EditModalModel.TenantInfoModel destin { destination.Id = source.Id; destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; @@ -74,6 +75,7 @@ public override TenantUpdateDto Map(EditModalModel.TenantInfoModel source) public override void Map(EditModalModel.TenantInfoModel source, TenantUpdateDto destination) { destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; @@ -96,6 +98,7 @@ public override void Map(TenantDto source, TenantInfoModel destination) { destination.Id = source.Id; destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; @@ -117,6 +120,7 @@ public override TenantUpdateDto Map(TenantInfoModel source) public override void Map(TenantInfoModel source, TenantUpdateDto destination) { destination.Name = source.Name; + destination.DisplayName = source.DisplayName; destination.Division = source.Division; destination.Branch = source.Branch; destination.Description = source.Description; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs index 96f8c73743..ebcd99601a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs @@ -75,6 +75,33 @@ public async Task GetListAsync_Sorted_Descending_By_Name() tenants.FindIndex(t => t.Name == "acme").ShouldBeGreaterThan(tenants.FindIndex(t => t.Name == "volosoft")); } + [Fact] + public async Task GetListAsync_Sorted_By_DisplayName() + { + var acme = UsingDbContext(dbContext => dbContext.Tenants.Single(t => t.Name == "acme")); + var volo = UsingDbContext(dbContext => dbContext.Tenants.Single(t => t.Name == "volosoft")); + + await _tenantAppService.UpdateAsync(acme.Id, new TenantUpdateDto { Name = "acme", DisplayName = "Zeta Corp" }); + await _tenantAppService.UpdateAsync(volo.Id, new TenantUpdateDto { Name = "volosoft", DisplayName = "Alpha Corp" }); + + var result = await _tenantAppService.GetListAsync(new GetTenantsInput { Sorting = "DisplayName ASC" }); + var tenants = result.Items.ToList(); + + tenants.FindIndex(t => t.Name == "volosoft").ShouldBeLessThan(tenants.FindIndex(t => t.Name == "acme")); + } + + [Fact] + public async Task GetListAsync_Filtered_By_DisplayName() + { + var acme = UsingDbContext(dbContext => dbContext.Tenants.Single(t => t.Name == "acme")); + await _tenantAppService.UpdateAsync(acme.Id, new TenantUpdateDto { Name = "acme", DisplayName = "UniqueDisplayNameXyz" }); + + var result = await _tenantAppService.GetListAsync(new GetTenantsInput { Filter = "UniqueDisplayNameXyz" }); + + result.Items.ShouldContain(t => t.Name == "acme"); + result.Items.ShouldNotContain(t => t.Name == "volosoft"); + } + [Fact] public async Task CreateAsync() { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs index d0e68fdcb6..11ef370f63 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/PostTenantCreationSequenceJob.cs @@ -13,9 +13,16 @@ namespace Unity.GrantManager.Tenants.PostCreation; /// /// Runs the registered steps, one per job execution, in /// ascending order. Each execution re-enqueues itself -/// for the next step, so the sequence is driven entirely by ABP's background job queue - every -/// step is individually durable and retryable. A step whose -/// is false stops the sequence on failure; later steps are not run. +/// for the next step, so the sequence is driven entirely by ABP's background job queue. +/// +/// A step's exception is always caught and logged here rather than rethrown, so ABP's own +/// background-job retry mechanism (which only engages when ExecuteAsync throws) never +/// applies to an individual step - failures are best-effort, not retried automatically. A step +/// whose is true is logged and the sequence +/// moves on to the next step regardless; one whose ContinueOnError is false stops the sequence +/// entirely on failure (later steps do not run). Either way, the failed step itself does not get +/// another automatic attempt - recovering it currently requires manually re-enqueuing a +/// for that step index. /// public class PostTenantCreationSequenceJob( IEnumerable steps, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs index a3609f6c7c..418b20af9a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Tenants/PostCreation/Steps/MetabaseTenantRegistrationStep.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -114,8 +116,8 @@ public virtual async Task ExecuteAsync(Guid tenantId) if (userId == null) { logger.LogWarning( - "{Prefix} User '{MaskedEmail}' not found in Metabase for tenant {TenantId} - they must log in via LDAP or be created under Admin > People before they can be added to a group.", - LogPrefix, MaskEmail(email), tenantId); + "{Prefix} User (hash {EmailHash}) not found in Metabase for tenant {TenantId} - they must log in via LDAP or be created under Admin > People before they can be added to a group.", + LogPrefix, HashEmail(email), tenantId); continue; } await metabaseApiClient.AddGroupMemberAsync(groupId, userId.Value); @@ -142,14 +144,13 @@ private async Task> GetUserEmailsAsync(Guid tenantId) .ToList(); } - // Avoids writing a user's full email address to logs (CodeQL: exposure of private - // information) while keeping enough of it for an admin to correlate a "not found" warning - // with a known user. - private static string MaskEmail(string email) - { - var atIndex = email.IndexOf('@', StringComparison.Ordinal); - return atIndex <= 1 ? "***" : string.Concat(email.AsSpan(0, 1), "***", email.AsSpan(atIndex)); - } + // Avoids writing a user's email address to logs (CodeQL: exposure of private information). + // A substring/mask still contains real characters from the source string, so CodeQL's + // dataflow analysis still treats it as the same private data - a one-way hash is what's + // actually recognized as breaking that taint, while still letting an admin correlate repeated + // "not found" warnings against a known list of emails (by hashing candidates themselves). + private static string HashEmail(string email) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant())))[..8]; private static (string Host, int Port, string DbName, string Username, string Password) ParseConnectionString(string connectionString) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs index b34a84a2bb..5b68b508a4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs @@ -52,6 +52,18 @@ private static string GetMatomoUrl() return DynamicUrls.MATOMO_PROD_URL; } + // Unlike Matomo, only dev has a known route baked in here. Test/UAT/prod are deliberately + // left blank - ops sets the real URL once via the Endpoint Management admin page, and + // (unlike Matomo) it's never overwritten afterward: this only ever inserts a row when one + // doesn't already exist, so whatever's in the database always takes precedence. + private static string GetMetabaseUrl() + { + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty; + return string.IsNullOrEmpty(env) || env.StartsWith("dev", StringComparison.OrdinalIgnoreCase) + ? DynamicUrls.METABASE_DEV_URL + : string.Empty; + } + private async Task SeedDynamicUrlAsync() { if (currentTenant == null || currentTenant.Id == null) @@ -73,7 +85,7 @@ private async Task SeedDynamicUrlAsync() new() { KeyName = DynamicUrlKeyNames.ANALYTICS_MATOMO_BASE, Url = GetMatomoUrl(), Description = "Matomo Analytics" }, new() { KeyName = DynamicUrlKeyNames.GITHUB_REPO, Url = DynamicUrls.GITHUB_REPO, Description = "GitHub Repository" }, new() { KeyName = DynamicUrlKeyNames.GITHUB_GRAPHQL, Url = DynamicUrls.GITHUB_GRAPHQL, Description = "GitHub GraphQL Endpoint" }, - new() { KeyName = DynamicUrlKeyNames.METABASE_API_BASE, Url = DynamicUrls.METABASE_DEV_URL, Description = "Metabase Reporting API" }, + new() { KeyName = DynamicUrlKeyNames.METABASE_API_BASE, Url = GetMetabaseUrl(), Description = "Metabase Reporting API" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" },