diff --git a/db/migrations/20260616120000_add-capability-id-indexes.sql b/db/migrations/20260616120000_add-capability-id-indexes.sql new file mode 100644 index 00000000..eda00911 --- /dev/null +++ b/db/migrations/20260616120000_add-capability-id-indexes.sql @@ -0,0 +1,10 @@ +-- Indexes on the capability-id (and user-id) filter columns used by the bulk email-campaign +-- rendering path (one WHERE col = ANY(...) query per data source) and by existing single-capability +-- lookups. PostgreSQL does not auto-index foreign-key referencing columns, so without these each +-- query is a sequential scan whose cost grows with table size regardless of the number of ids. + +CREATE INDEX IF NOT EXISTS "IX_Membership_CapabilityId" ON "Membership" ("CapabilityId"); +CREATE INDEX IF NOT EXISTS "IX_Membership_UserId" ON "Membership" ("UserId"); +CREATE INDEX IF NOT EXISTS "IX_AwsAccount_CapabilityId" ON "AwsAccount" ("CapabilityId"); +CREATE INDEX IF NOT EXISTS "IX_AzureResource_CapabilityId" ON "AzureResource" ("CapabilityId"); +CREATE INDEX IF NOT EXISTS "IX_MembershipApplication_CapabilityId_Status" ON "MembershipApplication" ("CapabilityId", "Status"); diff --git a/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs b/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs index 869beb82..07d0523a 100644 --- a/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs +++ b/src/SelfService.Tests/Application/TestEmailCampaignApplicationService.cs @@ -1,10 +1,15 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Moq; using SelfService.Application; using SelfService.Domain.Models; +using SelfService.Domain.Queries; using SelfService.Domain.Services; +using SelfService.Infrastructure.Persistence; +using SelfService.Infrastructure.Persistence.Models; +using SelfService.Tests.Builders; namespace SelfService.Tests.Application; @@ -184,36 +189,29 @@ public async Task resolve_user_audience_filters_members_by_recipient_filter_role var rbac = new Mock(); rbac.Setup(x => x.GetAssignableRoles()).ReturnsAsync(new List { ownerRole, readerRole }); - rbac.Setup(x => x.GetRoleGrantsForUser(ownerMember.Id.ToString())) - .ReturnsAsync( - new List - { - new( - RbacRoleGrantId.New(), - ownerRole.Id, - DateTime.UtcNow, - AssignedEntityType.User, - ownerMember.Id.ToString(), - RbacAccessType.Capability, - "some-capability" - ), - } - ); - rbac.Setup(x => x.GetRoleGrantsForUser(readerMember.Id.ToString())) - .ReturnsAsync( - new List - { - new( - RbacRoleGrantId.New(), - readerRole.Id, - DateTime.UtcNow, - AssignedEntityType.User, - readerMember.Id.ToString(), - RbacAccessType.Capability, - "some-capability" - ), - } - ); + var roleGrants = new List + { + new( + RbacRoleGrantId.New(), + ownerRole.Id, + DateTime.UtcNow, + AssignedEntityType.User, + ownerMember.Id.ToString(), + RbacAccessType.Capability, + "some-capability" + ), + new( + RbacRoleGrantId.New(), + readerRole.Id, + DateTime.UtcNow, + AssignedEntityType.User, + readerMember.Id.ToString(), + RbacAccessType.Capability, + "some-capability" + ), + }; + rbac.Setup(x => x.GetRoleGrantsForUsers(It.IsAny>())) + .ReturnsAsync(roleGrants.ToLookup(g => g.AssignedEntityId)); var sut = BuildService(userFilter: userFilter.Object, rbac: rbac.Object); @@ -224,13 +222,106 @@ public async Task resolve_user_audience_filters_members_by_recipient_filter_role Assert.Equal("owner@dfds.com", result.Users[0].Email); } + [Fact] + public async Task user_capabilities_loop_excludes_non_active_capabilities() + { + // Memberships linger on capabilities that are deleted / pending deletion; those must NOT + // appear in the {{#each User.Capabilities}} loop — only active capabilities. + var member = new Member(UserId.Parse("user@dfds.com"), "user@dfds.com", "User", UserSettings.Default); + + var activeCap = A.Capability.WithId(CapabilityId.CreateFrom("active-cap")).WithName("Active Cap").Build(); + var deletedCap = A + .Capability.WithId(CapabilityId.CreateFrom("deleted-cap")) + .WithName("Deleted Cap") + .WithStatus(CapabilityStatusOptions.Deleted) + .Build(); + + var campaign = EmailCampaign.CreateDraft( + name: "Campaign", + subject: "Subject", + contentJson: "{}", + contentHtml: "{{#each User.Capabilities}}[{{Capability.Name}}]{{/each}}", + audienceJson: "{\"mode\":\"all\"}", + recipientFilter: null, + createdBy: "tester" + ); + + var campaignRepo = new Mock(); + campaignRepo.Setup(x => x.FindById(It.IsAny())).ReturnsAsync(campaign); + + var userFilter = new Mock(); + userFilter + .Setup(x => x.ResolveUsers(It.IsAny())) + .ReturnsAsync(new UserAudienceResolution { Members = new List { member } }); + + var membershipRepo = new Mock(); + membershipRepo + .Setup(x => x.GetAllMembershipsForUserIds(It.IsAny>())) + .ReturnsAsync( + new List + { + A.Membership.WithUserId(member.Id).WithCapabilityId(activeCap.Id), + A.Membership.WithUserId(member.Id).WithCapabilityId(deletedCap.Id), + } + ); + membershipRepo + .Setup(x => x.GetMemberCountsByCapabilityIds(It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var capabilityRepo = new Mock(); + capabilityRepo + .Setup(x => x.GetByIds(It.IsAny>())) + .ReturnsAsync(new List { activeCap, deletedCap }); + + var awsRepo = new Mock(); + awsRepo + .Setup(x => x.GetByCapabilityIds(It.IsAny>())) + .ReturnsAsync(new List()); + var azureRepo = new Mock(); + azureRepo + .Setup(x => x.GetForCapabilityIds(It.IsAny>())) + .ReturnsAsync(new List()); + var appQuery = new Mock(); + appQuery + .Setup(x => x.FindPendingByCapabilityIds(It.IsAny>())) + .ReturnsAsync(new List()); + var metricService = new Mock(); + metricService + .Setup(x => x.GetRequirementScoresForCapabilitiesAsync(It.IsAny>())) + .ReturnsAsync(new Dictionary>()); + + var scopeFactory = ScopeFactoryWith( + (typeof(IMembershipRepository), membershipRepo.Object), + (typeof(ICapabilityRepository), capabilityRepo.Object), + (typeof(IAwsAccountRepository), awsRepo.Object), + (typeof(IAzureResourceRepository), azureRepo.Object), + (typeof(ICapabilityMembershipApplicationQuery), appQuery.Object), + (typeof(IRequirementsMetricService), metricService.Object) + ); + + var sut = BuildService( + campaignRepo: campaignRepo.Object, + userFilter: userFilter.Object, + templateRendering: new TemplateRenderingService(), + scopeFactory: scopeFactory + ); + + var previews = await sut.PreviewUserCampaign(campaign.Id, new[] { member.Email }); + + Assert.Single(previews); + Assert.Contains("Active Cap", previews[0].Html); + Assert.DoesNotContain("Deleted Cap", previews[0].Html); + } + private static EmailCampaignApplicationService BuildService( IEmailCampaignRepository? campaignRepo = null, IEmailCampaignRecipientLogRepository? recipientLogRepo = null, IEmailCampaignExecutionRepository? executionRepo = null, ICapabilityFilterService? capabilityFilter = null, IUserFilterService? userFilter = null, - IRbacApplicationService? rbac = null + IRbacApplicationService? rbac = null, + ITemplateRenderingService? templateRendering = null, + IServiceScopeFactory? scopeFactory = null ) { return new EmailCampaignApplicationService( @@ -242,9 +333,25 @@ private static EmailCampaignApplicationService BuildService( Mock.Of(), capabilityFilter ?? Mock.Of(), userFilter ?? Mock.Of(), - Mock.Of(), + templateRendering ?? Mock.Of(), rbac ?? Mock.Of(), - Mock.Of() + scopeFactory ?? Mock.Of() ); } + + // Wires an IServiceScopeFactory whose scope resolves the given service instances — mirrors the + // scoped resolution LoadUserCapabilitiesForMembers performs at runtime. + private static IServiceScopeFactory ScopeFactoryWith(params (Type type, object impl)[] services) + { + var provider = new Mock(); + foreach (var (type, impl) in services) + provider.Setup(p => p.GetService(type)).Returns(impl); + + var scope = new Mock(); + scope.SetupGet(s => s.ServiceProvider).Returns(provider.Object); + + var factory = new Mock(); + factory.Setup(f => f.CreateScope()).Returns(scope.Object); + return factory.Object; + } } diff --git a/src/SelfService.Tests/Infrastructure/Api/TestCapabilityMembershipApplicationRoutes.cs b/src/SelfService.Tests/Infrastructure/Api/TestCapabilityMembershipApplicationRoutes.cs index 3896df3a..7983911f 100644 --- a/src/SelfService.Tests/Infrastructure/Api/TestCapabilityMembershipApplicationRoutes.cs +++ b/src/SelfService.Tests/Infrastructure/Api/TestCapabilityMembershipApplicationRoutes.cs @@ -468,4 +468,9 @@ public Task> FindPendingBy(CapabilityId capab { return Task.FromResult(_result.AsEnumerable()); } + + public Task> FindPendingByCapabilityIds(IEnumerable capabilityIds) + { + return Task.FromResult(_result.AsEnumerable()); + } } diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestMembershipRepository.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestMembershipRepository.cs index 2e4e6d2f..2ba3bde1 100644 --- a/src/SelfService.Tests/Infrastructure/Persistence/TestMembershipRepository.cs +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestMembershipRepository.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SelfService.Domain; +using SelfService.Domain.Models; using SelfService.Infrastructure.BackgroundJobs; using SelfService.Infrastructure.Persistence; using SelfService.Tests.Comparers; @@ -69,4 +70,66 @@ public async Task cancel_keeps_membership_for_capability_with_single_member() var remaining = await dbContext.Memberships.ToListAsync(); Assert.Contains(member, remaining, new MembershipComparer()); } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task get_member_counts_by_capability_ids_returns_count_per_capability() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var capA = A.Capability.Build(); + var capB = A.Capability.Build(); + var repo = A.MembershipRepository.WithDbContext(dbContext).Build(); + + await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U1").Build()); + await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U2").Build()); + await repo.Add(A.Membership.WithCapabilityId(capB.Id).WithUserId("U1").Build()); + await dbContext.SaveChangesAsync(); + + var counts = await repo.GetMemberCountsByCapabilityIds(new[] { capA.Id, capB.Id }); + + Assert.Equal(2, counts[capA.Id]); + Assert.Equal(1, counts[capB.Id]); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task get_member_counts_by_capability_ids_empty_input_returns_empty() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + var repo = A.MembershipRepository.WithDbContext(dbContext).Build(); + + var counts = await repo.GetMemberCountsByCapabilityIds(Array.Empty()); + + Assert.Empty(counts); + } + + [Fact] + [Trait("Category", "InMemoryDatabase")] + public async Task get_all_memberships_for_user_ids_returns_memberships_for_requested_users() + { + await using var databaseFactory = new InMemoryDatabaseFactory(); + var dbContext = await databaseFactory.CreateSelfServiceDbContext(); + + var capA = A.Capability.Build(); + var capB = A.Capability.Build(); + var repo = A.MembershipRepository.WithDbContext(dbContext).Build(); + + var u1 = UserId.Parse("U1"); + var u2 = UserId.Parse("U2"); + var u3 = UserId.Parse("U3"); + await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U1").Build()); + await repo.Add(A.Membership.WithCapabilityId(capB.Id).WithUserId("U1").Build()); + await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U2").Build()); + await repo.Add(A.Membership.WithCapabilityId(capA.Id).WithUserId("U3").Build()); + await dbContext.SaveChangesAsync(); + + var memberships = await repo.GetAllMembershipsForUserIds(new[] { u1, u2 }); + + Assert.Equal(3, memberships.Count); + Assert.All(memberships, m => Assert.Contains(m.UserId, new[] { u1, u2 })); + Assert.DoesNotContain(memberships, m => m.UserId == u3); + } } diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs index 66df7382..b04d7b02 100644 --- a/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs @@ -77,18 +77,23 @@ public void RenderTemplate_CampaignName_Renders() Assert.Equal("Campaign: Test Campaign", result); } + // 5 of the 6 mandatory tags present → 83.33 → "83". Non-tags scores still come from the requirements DB. + private const string FivePresentTagsMetadata = + "{\"dfds.cost.centre\":\"cc\",\"dfds.businessCapability\":\"bc\",\"dfds.env\":\"prod\"," + + "\"dfds.data.classification\":\"internal\",\"dfds.service.criticality\":\"high\"}"; + + private const string AllTagsMetadata = + "{\"dfds.cost.centre\":\"cc\",\"dfds.businessCapability\":\"bc\",\"dfds.env\":\"prod\"," + + "\"dfds.data.classification\":\"internal\",\"dfds.service.criticality\":\"high\"," + + "\"dfds.service.availability\":\"99.9\"}"; + [Fact] public void RenderTemplate_RequirementScore_RendersValue() { + // Tags derive from the capability metadata (like the compliance endpoint); other scores from the DB. + var capability = A.Capability.WithJsonMetadata(FivePresentTagsMetadata).Build(); var scores = new List { - new() - { - RequirementId = "mandatory_tags", - Value = 83.3, - DisplayName = "Use of Mandatory Tags", - HelpUrl = "https://wiki.example.com/tags", - }, new() { RequirementId = "external_secrets", @@ -96,7 +101,7 @@ public void RenderTemplate_RequirementScore_RendersValue() DisplayName = "External Secrets Adoption", }, }; - var context = CreateContext(requirementScores: scores); + var context = CreateContext(capability: capability, requirementScores: scores); var result = _sut.RenderTemplate( "Tags: {{Requirement.mandatory_tags}}, Secrets: {{Requirement.external_secrets}}", @@ -106,23 +111,56 @@ public void RenderTemplate_RequirementScore_RendersValue() Assert.Equal("Tags: 83, Secrets: 100", result); } + [Fact] + public void RenderTemplate_RequirementScore_Tags_IgnoresRequirementsDbMetric() + { + // A stale mandatory_tags metric in the requirements DB must not override the metadata-derived score. + var capability = A.Capability.WithJsonMetadata(AllTagsMetadata).Build(); + var scores = new List + { + new() { RequirementId = "mandatory_tags", Value = 12 }, + }; + var context = CreateContext(capability: capability, requirementScores: scores); + + var result = _sut.RenderTemplate("{{Requirement.mandatory_tags}}", context); + + Assert.Equal("100", result); + } + [Fact] public void RenderTemplate_RequirementScore_DisplayName_Renders() { + // Non-tags requirements take DisplayName from the requirements DB metric. var scores = new List { new() { - RequirementId = "mandatory_tags", + RequirementId = "external_secrets", Value = 80, - DisplayName = "Use of Mandatory Tags", + DisplayName = "External Secrets Adoption", }, }; var context = CreateContext(requirementScores: scores); - var result = _sut.RenderTemplate("{{Requirement.mandatory_tags.DisplayName}}", context); + var result = _sut.RenderTemplate("{{Requirement.external_secrets.DisplayName}}", context); - Assert.Equal("Use of Mandatory Tags", result); + Assert.Equal("External Secrets Adoption", result); + } + + [Fact] + public void RenderTemplate_RequirementScore_Tags_DisplayNameAndHelpUrl_UseComplianceConstants() + { + // Tags DisplayName/HelpUrl mirror the compliance endpoint, independent of the requirements DB. + var context = CreateContext(); + + Assert.Equal( + TagComplianceEvaluator.DisplayName, + _sut.RenderTemplate("{{Requirement.mandatory_tags.DisplayName}}", context) + ); + Assert.Equal( + TagComplianceEvaluator.HelpUrl, + _sut.RenderTemplate("{{Requirement.mandatory_tags.HelpUrl}}", context) + ); } [Fact] @@ -180,13 +218,25 @@ public void RenderTemplate_RequirementScore_UnknownId_RendersNA() [Fact] public void RenderTemplate_RequirementScore_EmptyScores_RendersNA() { + // Non-tags requirements with no DB metric still fall back to N/A. var context = CreateContext(requirementScores: new List()); - var result = _sut.RenderTemplate("{{Requirement.mandatory_tags}}", context); + var result = _sut.RenderTemplate("{{Requirement.external_secrets}}", context); Assert.Equal("N/A", result); } + [Fact] + public void RenderTemplate_RequirementScore_Tags_NoTagsInMetadata_RendersZero() + { + // Default capability metadata "{}" has none of the required tags → 0 (not N/A). + var context = CreateContext(requirementScores: new List()); + + var result = _sut.RenderTemplate("{{Requirement.mandatory_tags}}", context); + + Assert.Equal("0", result); + } + [Fact] public void RenderTemplate_AwsAccount_Present_RendersFields() { @@ -301,22 +351,14 @@ public void RenderTemplate_PendingMembershipApplicationCount_Zero_RendersZero() [Fact] public void RenderTemplate_AllNewVariablesCombined() { - var scores = new List - { - new() - { - RequirementId = "mandatory_tags", - Value = 100, - DisplayName = "Tags", - }, - }; + var capability = A.Capability.WithJsonMetadata(AllTagsMetadata).Build(); var awsAccount = A.AwsAccount.Build(); awsAccount.RegisterRealAwsAccount("999888777666", "role@dfds.com", DateTime.UtcNow); var resources = new List { A.AzureResource.WithEnvironment("dev").Build() }; var context = CreateContext( + capability: capability, awsAccount: awsAccount, azureResources: resources, - requirementScores: scores, pendingMembershipApplicationCount: 2 ); @@ -453,6 +495,64 @@ public void RenderTemplate_EachUserCapabilities_RendersOnePerCapability() Assert.Equal("
  • Cap A (4)
  • Cap B (7)
", result); } + [Fact] + public void RenderTemplate_EachUserCapabilities_RendersPerCapabilityRequirementAndCounts() + { + // Regression: per-capability data (requirement scores, pending applications, Azure) must be + // carried into each {{#each User.Capabilities}} iteration instead of always rendering "N/A". + var capA = A.Capability.WithName("Cap A").Build(); + var capB = A.Capability.WithName("Cap B").Build(); + var ctx = UserContext( + userCapabilities: new List + { + new() + { + Capability = capA, + MemberCount = 4, + RequirementScores = new List + { + new() { RequirementId = "external_secrets", Value = 100 }, + }, + PendingMembershipApplicationCount = 2, + }, + new() + { + Capability = capB, + MemberCount = 7, + RequirementScores = new List + { + new() { RequirementId = "external_secrets", Value = 40 }, + }, + PendingMembershipApplicationCount = 0, + }, + } + ); + + var result = _sut.RenderTemplate( + "{{#each User.Capabilities}}[{{Capability.Name}}: secrets={{Requirement.external_secrets}}, pending={{MembershipApplications.PendingCount}}]{{/each}}", + ctx + ); + + Assert.Equal("[Cap A: secrets=100, pending=2][Cap B: secrets=40, pending=0]", result); + } + + [Fact] + public void RenderTemplate_EachUserCapabilities_MissingRequirement_FallsBackToNA() + { + // A capability with no requirement scores still falls back to N/A (no data leaks in). + var cap = A.Capability.WithName("Cap A").Build(); + var ctx = UserContext( + userCapabilities: new List + { + new() { Capability = cap, MemberCount = 1 }, + } + ); + + var result = _sut.RenderTemplate("{{#each User.Capabilities}}{{Requirement.external_secrets}}{{/each}}", ctx); + + Assert.Equal("N/A", result); + } + [Fact] public void RenderTemplate_EachUserCapabilities_EmptyList_RendersNothingForBlock() { diff --git a/src/SelfService.Tests/TestDoubles/StubCapabilityRepository.cs b/src/SelfService.Tests/TestDoubles/StubCapabilityRepository.cs index 65ddc0f3..01e3883b 100644 --- a/src/SelfService.Tests/TestDoubles/StubCapabilityRepository.cs +++ b/src/SelfService.Tests/TestDoubles/StubCapabilityRepository.cs @@ -21,6 +21,13 @@ public Task Get(CapabilityId id) return Task.FromResult(_capability); } + public Task> GetByIds(IEnumerable ids) + { + return Task.FromResult( + _capability is null ? Enumerable.Empty() : new[] { _capability }.AsEnumerable() + ); + } + public Task Exists(CapabilityId id) { return Task.FromResult(_capability != null); diff --git a/src/SelfService.Tests/TestDoubles/StubRbacRoleGrantRepository.cs b/src/SelfService.Tests/TestDoubles/StubRbacRoleGrantRepository.cs index 07f10ab3..690338a2 100644 --- a/src/SelfService.Tests/TestDoubles/StubRbacRoleGrantRepository.cs +++ b/src/SelfService.Tests/TestDoubles/StubRbacRoleGrantRepository.cs @@ -49,4 +49,9 @@ public Task> GetAllWithPredicate(Func p { return Task.FromResult(new List()); } + + public Task> GetByAssignedUsers(IReadOnlyCollection userIds) + { + return Task.FromResult(new List()); + } } diff --git a/src/SelfService.Tests/TestDoubles/StupRbacApplicationService.cs b/src/SelfService.Tests/TestDoubles/StupRbacApplicationService.cs index ae5b3aac..7d43783e 100644 --- a/src/SelfService.Tests/TestDoubles/StupRbacApplicationService.cs +++ b/src/SelfService.Tests/TestDoubles/StupRbacApplicationService.cs @@ -98,6 +98,13 @@ public Task> GetRoleGrantsForUser(string user) return Task.FromResult(_roleGrants ?? new List()); } + public Task> GetRoleGrantsForUsers(IReadOnlyCollection userIds) + { + var grants = _roleGrants ?? new List(); + var lookup = userIds.SelectMany(id => grants.Select(g => (id, g))).ToLookup(x => x.id, x => x.g); + return Task.FromResult(lookup); + } + public Task> GetAllRoles() { return Task.FromResult(_assignableRoles ?? new List()); diff --git a/src/SelfService/Application/ComplianceApplicationService.cs b/src/SelfService/Application/ComplianceApplicationService.cs index 21ca0d8a..3cbe929d 100644 --- a/src/SelfService/Application/ComplianceApplicationService.cs +++ b/src/SelfService/Application/ComplianceApplicationService.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using SelfService.Domain.Exceptions; using SelfService.Domain.Models; +using SelfService.Domain.Services; using SelfService.Infrastructure.Persistence; using SelfService.Infrastructure.Persistence.Models; @@ -13,16 +14,6 @@ public class ComplianceApplicationService : IComplianceApplicationService private readonly IAwsAccountRepository _awsAccountRepository; private readonly RequirementsDbContext _requirementsDbContext; - private static readonly string[] RequiredTags = - { - "dfds.cost.centre", - "dfds.businessCapability", - "dfds.env", - "dfds.data.classification", - "dfds.service.criticality", - "dfds.service.availability", - }; - private static readonly string[] PlaceholderCategories = Array.Empty(); private static readonly string[] Categories = @@ -180,42 +171,26 @@ public async Task GetCostCentreComplianceDeta }; } - private ComplianceCategoryResult CheckTagCompliance(string? jsonMetadata) + private static ComplianceCategoryResult CheckTagCompliance(string? jsonMetadata) { - var items = new List(); - JsonObject? jsonObject = null; - - if (!string.IsNullOrEmpty(jsonMetadata)) - { - jsonObject = JsonNode.Parse(jsonMetadata)?.AsObject(); - } - - foreach (var tag in RequiredTags) - { - var value = jsonObject?[tag]?.ToString(); - var isPresent = !string.IsNullOrEmpty(value); - items.Add( - new ComplianceCategoryItem - { - Name = tag, - Status = isPresent ? "present" : "missing", - Detail = isPresent ? value : null, - } - ); - } - - var presentCount = items.Count(i => i.Status == "present"); - var score = (double)presentCount / items.Count * 100; + var evaluation = TagComplianceEvaluator.Evaluate(jsonMetadata); return new ComplianceCategoryResult { - CategoryName = "Tags", - Status = presentCount == items.Count ? ComplianceStatus.Compliant : ComplianceStatus.NonCompliant, - Score = score, - Items = items, - Description = "Mandatory tags on a Capability level", - HelpUrl = "https://wiki.dfds.cloud/en/playbooks/requirements/Mandatory-tags-for-capabilities", - DisplayName = "Tags for Capabilities", + CategoryName = TagComplianceEvaluator.CategoryName, + Status = evaluation.IsCompliant ? ComplianceStatus.Compliant : ComplianceStatus.NonCompliant, + Score = evaluation.Score, + Items = evaluation + .Tags.Select(t => new ComplianceCategoryItem + { + Name = t.Name, + Status = t.IsPresent ? "present" : "missing", + Detail = t.Value, + }) + .ToList(), + Description = TagComplianceEvaluator.Description, + HelpUrl = TagComplianceEvaluator.HelpUrl, + DisplayName = TagComplianceEvaluator.DisplayName, }; } diff --git a/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs b/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs index 934a4592..0f9fdc6a 100644 --- a/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs +++ b/src/SelfService/Application/DeactivatedMemberCleanerApplicationService.cs @@ -93,8 +93,8 @@ public async Task RemoveDeactivatedMemberships(IUserStatusChecker userStatusChec } else { - _logger.LogDebug( - "Removing {DeactivatedMembersCount} members, disabled or not found in Azure AD:\\n{DeactivatedMembers}", + _logger.LogWarning( + "Removing {DeactivatedMembersCount} members with deactivated/disabled accounts in Azure AD:\n{DeactivatedMembers}", deactivatedMembers.Count, ToIdStringList(deactivatedMembers) ); @@ -112,20 +112,22 @@ public async Task RemoveDeactivatedMemberships(IUserStatusChecker userStatusChec await _membershipRepository.CancelAllMembershipsWithUserId(member.Id); } - _logger.LogDebug("Successfully cancelled memberships of users with deactivated accounts"); + _logger.LogInformation("Successfully cancelled memberships of users with deactivated accounts"); foreach (var member in membersToBeDeleted) { await _membershipApplicationRepository.RemoveAllWithUserId(member.Id); } - _logger.LogDebug("Successfully removed pending membership applications of users with deactivated accounts"); + _logger.LogInformation( + "Successfully removed pending membership applications of users with deactivated accounts" + ); foreach (var member in membersToBeDeleted) { await _memberRepository.Remove(member.Id); } - _logger.LogDebug("Successfully removed member entries of users with deactivated accounts"); + _logger.LogInformation("Successfully removed member entries of users with deactivated accounts"); } } diff --git a/src/SelfService/Application/EmailCampaignApplicationService.cs b/src/SelfService/Application/EmailCampaignApplicationService.cs index 2cc2f62c..57c69895 100644 --- a/src/SelfService/Application/EmailCampaignApplicationService.cs +++ b/src/SelfService/Application/EmailCampaignApplicationService.cs @@ -4,6 +4,7 @@ using SelfService.Domain.Models; using SelfService.Domain.Queries; using SelfService.Domain.Services; +using SelfService.Infrastructure.Persistence.Models; namespace SelfService.Application; @@ -203,14 +204,14 @@ private async Task> FilterMembersByRoles(List members, Hash if (matchingRoleIds == null || matchingRoleIds.Count == 0) return emailEligible; - var result = new List(emailEligible.Count); - foreach (var member in emailEligible) - { - var grants = await _rbacApplicationService.GetRoleGrantsForUser(member.Id.ToString()); - if (grants.Any(g => matchingRoleIds.Contains(g.RoleId))) - result.Add(member); - } - return result; + // Bulk-load role grants for every candidate in a single query instead of one query per member + // (the per-member lookup full-table-scans RbacRoleGrants, so a large audience meant N scans). + var userIds = emailEligible.Select(m => m.Id.ToString()).ToList(); + var grantsByUser = await _rbacApplicationService.GetRoleGrantsForUsers(userIds); + + return emailEligible + .Where(member => grantsByUser[member.Id.ToString()].Any(g => matchingRoleIds.Contains(g.RoleId))) + .ToList(); } public async Task> PreviewUserCampaign(EmailCampaignId id, string[]? userEmails) @@ -237,15 +238,16 @@ public async Task> PreviewUserCampaign(EmailCampaig var previews = new List(); var html = campaign.ContentHtml ?? ""; + var userCapabilitiesByMember = await LoadUserCapabilitiesForMembers(members); + foreach (var member in members) { - var userCapabilities = await LoadUserCapabilities(member.Id); var context = new TemplateRenderContext { Capability = null, Member = member, CampaignName = campaign.Name, - UserCapabilities = userCapabilities, + UserCapabilities = userCapabilitiesByMember.GetValueOrDefault(member.Id) ?? new(), }; previews.Add( @@ -536,22 +538,21 @@ private async Task SendToUserRecipients(EmailCampaign campaign, EmailCampai var recipientLogs = new List(); var html = campaign.ContentHtml ?? ""; - foreach (var member in members) - { - if (recipientLogs.Count >= EmailCampaign.MaxRecipientsPerCampaign) - break; - - if (string.IsNullOrEmpty(member.Email)) - continue; - - var userCapabilities = await LoadUserCapabilities(member.Id); + // Restrict to deliverable recipients up front, then bulk-load their capability data once. + var recipients = members + .Where(m => !string.IsNullOrEmpty(m.Email)) + .Take(EmailCampaign.MaxRecipientsPerCampaign) + .ToList(); + var userCapabilitiesByMember = await LoadUserCapabilitiesForMembers(recipients); + foreach (var member in recipients) + { var context = new TemplateRenderContext { Capability = null, Member = member, CampaignName = campaign.Name, - UserCapabilities = userCapabilities, + UserCapabilities = userCapabilitiesByMember.GetValueOrDefault(member.Id) ?? new(), }; var renderedSubject = _templateRenderingService.RenderTemplate(campaign.Subject, context); @@ -577,18 +578,85 @@ private async Task SendToUserRecipients(EmailCampaign campaign, EmailCampai return recipientLogs.Count; } - private async Task> LoadUserCapabilities(UserId userId) + /// + /// Builds the per-recipient lists used by the + /// {{#each User.Capabilities}} template block. All per-capability data (capability, + /// member count, AWS account, Azure resources, requirement scores, pending applications) + /// is bulk-loaded with one query per data source for the distinct set of capabilities + /// across ALL recipients — never one query per capability per recipient. + /// + private async Task>> LoadUserCapabilitiesForMembers( + IReadOnlyCollection members + ) { - var memberships = await _membershipRepository.GetAllMembershipsForUserId(userId); - var result = new List(memberships.Count); + if (members.Count == 0) + return new Dictionary>(); + + using var scope = _serviceScopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + var membershipRepository = sp.GetRequiredService(); + var capabilityRepository = sp.GetRequiredService(); + var awsAccountRepository = sp.GetRequiredService(); + var azureResourceRepository = sp.GetRequiredService(); + var membershipApplicationQuery = sp.GetRequiredService(); + var requirementsMetricService = sp.GetRequiredService(); + + // One query for all recipients' memberships, grouped to capability ids per user. + var memberIds = members.Select(m => m.Id).ToList(); + var memberships = await membershipRepository.GetAllMembershipsForUserIds(memberIds); + var capIdsByUser = memberships + .GroupBy(m => m.UserId) + .ToDictionary(g => g.Key, g => g.Select(x => x.CapabilityId).Distinct().ToList()); + + var allCapIds = capIdsByUser.Values.SelectMany(x => x).Distinct().ToList(); + if (allCapIds.Count == 0) + return members.ToDictionary(m => m.Id, _ => new List()); + + // One bulk query per data source for the distinct capability set. + // Only active capabilities surface in {{#each User.Capabilities}} — memberships linger on + // capabilities that are pending deletion or deleted, so they must be filtered out here. + var caps = (await capabilityRepository.GetByIds(allCapIds)) + .Where(c => c.Status == CapabilityStatusOptions.Active) + .ToDictionary(c => c.Id); + var aws = (await awsAccountRepository.GetByCapabilityIds(allCapIds)) + .GroupBy(a => a.CapabilityId) + .ToDictionary(g => g.Key, g => g.First()); + var azure = (await azureResourceRepository.GetForCapabilityIds(allCapIds)) + .GroupBy(r => r.CapabilityId) + .ToDictionary(g => g.Key, g => g.ToList()); + var pending = (await membershipApplicationQuery.FindPendingByCapabilityIds(allCapIds)) + .GroupBy(a => a.CapabilityId) + .ToDictionary(g => g.Key, g => g.Count()); + var memberCounts = await membershipRepository.GetMemberCountsByCapabilityIds(allCapIds); + var requirementScores = await requirementsMetricService.GetRequirementScoresForCapabilitiesAsync( + allCapIds.Select(id => id.ToString()).ToList() + ); - foreach (var membership in memberships) + var result = new Dictionary>(); + foreach (var member in members) { - var cap = await _capabilityRepository.FindBy(membership.CapabilityId); - if (cap == null) - continue; - var memberCount = (await _membershipRepository.FindBy(cap.Id)).Count(); - result.Add(new UserCapabilityRef { Capability = cap, MemberCount = memberCount }); + var refs = new List(); + if (capIdsByUser.TryGetValue(member.Id, out var capIds)) + { + foreach (var capId in capIds) + { + if (!caps.TryGetValue(capId, out var cap)) + continue; + refs.Add( + new UserCapabilityRef + { + Capability = cap, + MemberCount = memberCounts.GetValueOrDefault(capId), + AwsAccount = aws.GetValueOrDefault(capId), + AzureResources = azure.GetValueOrDefault(capId) ?? new List(), + RequirementScores = + requirementScores.GetValueOrDefault(capId.ToString()) ?? new List(), + PendingMembershipApplicationCount = pending.GetValueOrDefault(capId), + } + ); + } + } + result[member.Id] = refs; } return result; diff --git a/src/SelfService/Application/IRbacApplicationService.cs b/src/SelfService/Application/IRbacApplicationService.cs index efad4f01..365d085e 100644 --- a/src/SelfService/Application/IRbacApplicationService.cs +++ b/src/SelfService/Application/IRbacApplicationService.cs @@ -9,6 +9,12 @@ public interface IRbacApplicationService Task> GetPermissionGrantsForRoleGrants(List roleGrants); Task> GetPermissionGrantsForUser(string user); Task> GetRoleGrantsForUser(string user); + + /// + /// Bulk variant of : loads role grants for many users in a single + /// query and groups them by user id. Use this instead of calling the per-user method in a loop. + /// + Task> GetRoleGrantsForUsers(IReadOnlyCollection userIds); Task> GetPermissionGrantsForGroup(string groupId); Task> GetPermissionGrantsForRole(string roleId); Task> GetPermissionGrantsForRoleIgnoreCase(string roleId); diff --git a/src/SelfService/Application/IRequirementsMetricService.cs b/src/SelfService/Application/IRequirementsMetricService.cs index f96e464a..9e068232 100644 --- a/src/SelfService/Application/IRequirementsMetricService.cs +++ b/src/SelfService/Application/IRequirementsMetricService.cs @@ -11,5 +11,9 @@ public interface IRequirementsMetricService )> GetRequirementScoreAsync(string capabilityId); Task> GetAllRequirementScoresAsync(); + + Task< + Dictionary> + > GetRequirementScoresForCapabilitiesAsync(IReadOnlyCollection capabilityIds); } } diff --git a/src/SelfService/Application/RbacApplicationService.cs b/src/SelfService/Application/RbacApplicationService.cs index 251ef4ec..80b0f7cc 100644 --- a/src/SelfService/Application/RbacApplicationService.cs +++ b/src/SelfService/Application/RbacApplicationService.cs @@ -195,6 +195,15 @@ public async Task> GetRoleGrantsForUser(string user) ); } + public async Task> GetRoleGrantsForUsers(IReadOnlyCollection userIds) + { + if (userIds.Count == 0) + return Enumerable.Empty().ToLookup(g => g.AssignedEntityId); + + var grants = await _roleGrantRepository.GetByAssignedUsers(userIds); + return grants.ToLookup(g => g.AssignedEntityId); + } + public async Task> GetPermissionGrantsForGroup(string groupId) { return await _cache.GetOrAddAsync( diff --git a/src/SelfService/Application/RequirementsMetricService.cs b/src/SelfService/Application/RequirementsMetricService.cs index a9e74720..3cf084ec 100644 --- a/src/SelfService/Application/RequirementsMetricService.cs +++ b/src/SelfService/Application/RequirementsMetricService.cs @@ -39,6 +39,22 @@ public async Task> GetAllRequirementScoresAsync() return metrics.GroupBy(m => m.CapabilityRootId).ToDictionary(g => g.Key, g => g.Average(m => m.Value)); } + + public async Task< + Dictionary> + > GetRequirementScoresForCapabilitiesAsync(IReadOnlyCollection capabilityIds) + { + var ids = capabilityIds.Distinct().ToList(); + if (ids.Count == 0) + { + return new Dictionary>(); + } + var metrics = await _requirementsDbContext + .Metrics.Where(x => ids.Contains(x.CapabilityRootId)) + .Where(x => x.Measurement == "score") + .ToListAsync(); + return metrics.GroupBy(m => m.CapabilityRootId).ToDictionary(g => g.Key, g => g.ToList()); + } } // Stub implementation for when RequirementsDbContext is not available @@ -55,5 +71,10 @@ public class StubRequirementsMetricService : IRequirementsMetricService public Task> GetAllRequirementScoresAsync() => Task.FromResult(new Dictionary()); + + public Task< + Dictionary> + > GetRequirementScoresForCapabilitiesAsync(IReadOnlyCollection capabilityIds) => + Task.FromResult(new Dictionary>()); } } diff --git a/src/SelfService/Application/StubComplianceApplicationService.cs b/src/SelfService/Application/StubComplianceApplicationService.cs index d8a8f679..e1edfc0d 100644 --- a/src/SelfService/Application/StubComplianceApplicationService.cs +++ b/src/SelfService/Application/StubComplianceApplicationService.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using SelfService.Domain.Exceptions; using SelfService.Domain.Models; +using SelfService.Domain.Services; namespace SelfService.Application; @@ -9,16 +10,6 @@ public class StubComplianceApplicationService : IComplianceApplicationService private readonly ICapabilityRepository _capabilityRepository; private readonly IAwsAccountRepository _awsAccountRepository; - private static readonly string[] RequiredTags = - { - "dfds.cost.centre", - "dfds.businessCapability", - "dfds.env", - "dfds.data.classification", - "dfds.service.criticality", - "dfds.service.availability", - }; - private static readonly string[] PlaceholderCategories = Array.Empty(); public StubComplianceApplicationService( @@ -243,37 +234,24 @@ private async Task HasKubernetesContext(CapabilityId capabilityId) private static ComplianceCategoryResult CheckTagCompliance(string? jsonMetadata) { - var items = new List(); - JsonObject? jsonObject = null; - - if (!string.IsNullOrEmpty(jsonMetadata)) - { - jsonObject = JsonNode.Parse(jsonMetadata)?.AsObject(); - } - - foreach (var tag in RequiredTags) - { - var value = jsonObject?[tag]?.ToString(); - var isPresent = !string.IsNullOrEmpty(value); - items.Add( - new ComplianceCategoryItem - { - Name = tag, - Status = isPresent ? "present" : "missing", - Detail = isPresent ? value : null, - } - ); - } - - var presentCount = items.Count(i => i.Status == "present"); - var score = (double)presentCount / items.Count * 100; + var evaluation = TagComplianceEvaluator.Evaluate(jsonMetadata); return new ComplianceCategoryResult { - CategoryName = "Tags", - Status = presentCount == items.Count ? ComplianceStatus.Compliant : ComplianceStatus.NonCompliant, - Score = score, - Items = items, + CategoryName = TagComplianceEvaluator.CategoryName, + Status = evaluation.IsCompliant ? ComplianceStatus.Compliant : ComplianceStatus.NonCompliant, + Score = evaluation.Score, + Items = evaluation + .Tags.Select(t => new ComplianceCategoryItem + { + Name = t.Name, + Status = t.IsPresent ? "present" : "missing", + Detail = t.Value, + }) + .ToList(), + Description = TagComplianceEvaluator.Description, + HelpUrl = TagComplianceEvaluator.HelpUrl, + DisplayName = TagComplianceEvaluator.DisplayName, }; } diff --git a/src/SelfService/Domain/Models/IAzureResourceRepository.cs b/src/SelfService/Domain/Models/IAzureResourceRepository.cs index fc4b0b6f..552b3387 100644 --- a/src/SelfService/Domain/Models/IAzureResourceRepository.cs +++ b/src/SelfService/Domain/Models/IAzureResourceRepository.cs @@ -3,6 +3,7 @@ namespace SelfService.Domain.Models; public interface IAzureResourceRepository { Task> GetFor(CapabilityId capabilityId); + Task> GetForCapabilityIds(IEnumerable capabilityIds); Task> GetAll(); Task Add(AzureResource azureResource); Task Get(AzureResourceId id); diff --git a/src/SelfService/Domain/Models/ICapabilityRepository.cs b/src/SelfService/Domain/Models/ICapabilityRepository.cs index df13da7a..fa48ed5f 100644 --- a/src/SelfService/Domain/Models/ICapabilityRepository.cs +++ b/src/SelfService/Domain/Models/ICapabilityRepository.cs @@ -4,6 +4,7 @@ public interface ICapabilityRepository { Task Get(CapabilityId id); Task FindBy(CapabilityId id); + Task> GetByIds(IEnumerable ids); Task Exists(CapabilityId id); Task Add(Capability capability); Task> GetAll(); diff --git a/src/SelfService/Domain/Models/IMembershipRepository.cs b/src/SelfService/Domain/Models/IMembershipRepository.cs index 7158c5a5..075fe3cf 100644 --- a/src/SelfService/Domain/Models/IMembershipRepository.cs +++ b/src/SelfService/Domain/Models/IMembershipRepository.cs @@ -7,4 +7,6 @@ public interface IMembershipRepository : IGenericRepository CancelWithCapabilityId(CapabilityId capabilityId, UserId userId); Task> CancelAllMembershipsWithUserId(UserId userId); Task> GetAllMembershipsForUserId(UserId userId); + Task> GetAllMembershipsForUserIds(IEnumerable userIds); + Task> GetMemberCountsByCapabilityIds(IEnumerable capabilityIds); } diff --git a/src/SelfService/Domain/Models/IRbacRoleGrantRepository.cs b/src/SelfService/Domain/Models/IRbacRoleGrantRepository.cs index fe309a70..f16f8a17 100644 --- a/src/SelfService/Domain/Models/IRbacRoleGrantRepository.cs +++ b/src/SelfService/Domain/Models/IRbacRoleGrantRepository.cs @@ -1,3 +1,10 @@ namespace SelfService.Domain.Models; -public interface IRbacRoleGrantRepository : IGenericRepository { } +public interface IRbacRoleGrantRepository : IGenericRepository +{ + /// + /// Bulk-loads role grants assigned directly to the given users in a single query. + /// Avoids the N+1 of calling a per-user lookup in a loop. + /// + Task> GetByAssignedUsers(IReadOnlyCollection userIds); +} diff --git a/src/SelfService/Domain/Queries/ICapabilityMembershipApplicationQuery.cs b/src/SelfService/Domain/Queries/ICapabilityMembershipApplicationQuery.cs index 15516b11..a2dca462 100644 --- a/src/SelfService/Domain/Queries/ICapabilityMembershipApplicationQuery.cs +++ b/src/SelfService/Domain/Queries/ICapabilityMembershipApplicationQuery.cs @@ -5,4 +5,5 @@ namespace SelfService.Domain.Queries; public interface ICapabilityMembershipApplicationQuery { Task> FindPendingBy(CapabilityId capabilityId); + Task> FindPendingByCapabilityIds(IEnumerable capabilityIds); } diff --git a/src/SelfService/Domain/Services/TagComplianceEvaluator.cs b/src/SelfService/Domain/Services/TagComplianceEvaluator.cs new file mode 100644 index 00000000..acb9b83a --- /dev/null +++ b/src/SelfService/Domain/Services/TagComplianceEvaluator.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Nodes; + +namespace SelfService.Domain.Services; + +/// +/// Single source of truth for evaluating a capability's mandatory-tag compliance from its +/// JSON metadata. Used by both the compliance endpoint (GET /compliance/capabilities/{id}) and the +/// email template engine so the Tags score is computed identically in both places. +/// +public static class TagComplianceEvaluator +{ + public const string RequirementId = "mandatory_tags"; + public const string CategoryName = "Tags"; + public const string DisplayName = "Tags for Capabilities"; + public const string HelpUrl = "https://wiki.dfds.cloud/en/playbooks/requirements/Mandatory-tags-for-capabilities"; + public const string Description = "Mandatory tags on a Capability level"; + + public static readonly IReadOnlyList RequiredTags = new[] + { + "dfds.cost.centre", + "dfds.businessCapability", + "dfds.env", + "dfds.data.classification", + "dfds.service.criticality", + "dfds.service.availability", + }; + + public record TagResult(string Name, bool IsPresent, string? Value); + + public record TagEvaluation(IReadOnlyList Tags, double Score, bool IsCompliant); + + public static TagEvaluation Evaluate(string? jsonMetadata) + { + JsonObject? jsonObject = null; + if (!string.IsNullOrEmpty(jsonMetadata)) + { + jsonObject = JsonNode.Parse(jsonMetadata)?.AsObject(); + } + + var tags = new List(); + foreach (var tag in RequiredTags) + { + var value = jsonObject?[tag]?.ToString(); + var isPresent = !string.IsNullOrEmpty(value); + tags.Add(new TagResult(tag, isPresent, isPresent ? value : null)); + } + + var presentCount = tags.Count(t => t.IsPresent); + var score = (double)presentCount / tags.Count * 100; + + return new TagEvaluation(tags, score, presentCount == tags.Count); + } +} diff --git a/src/SelfService/Domain/Services/TemplateRenderContext.cs b/src/SelfService/Domain/Services/TemplateRenderContext.cs index f05da934..559abdf1 100644 --- a/src/SelfService/Domain/Services/TemplateRenderContext.cs +++ b/src/SelfService/Domain/Services/TemplateRenderContext.cs @@ -30,4 +30,8 @@ public record UserCapabilityRef { public required Capability Capability { get; init; } public int MemberCount { get; init; } + public AwsAccount? AwsAccount { get; init; } + public List AzureResources { get; init; } = new(); + public List RequirementScores { get; init; } = new(); + public int PendingMembershipApplicationCount { get; init; } } diff --git a/src/SelfService/Infrastructure/Persistence/AzureResourceRepository.cs b/src/SelfService/Infrastructure/Persistence/AzureResourceRepository.cs index 3e9a5521..d05dd677 100644 --- a/src/SelfService/Infrastructure/Persistence/AzureResourceRepository.cs +++ b/src/SelfService/Infrastructure/Persistence/AzureResourceRepository.cs @@ -23,6 +23,16 @@ public async Task> GetFor(CapabilityId capabilityId) return await _dbContext.AzureResources.Where(x => x.CapabilityId == capabilityId).ToListAsync(); } + public async Task> GetForCapabilityIds(IEnumerable capabilityIds) + { + var idList = capabilityIds.Distinct().ToList(); + if (idList.Count == 0) + { + return new List(); + } + return await _dbContext.AzureResources.Where(x => idList.Contains(x.CapabilityId)).ToListAsync(); + } + public async Task Get(AzureResourceId id) { var found = await _dbContext.AzureResources.FindAsync(id); diff --git a/src/SelfService/Infrastructure/Persistence/CapabilityRepository.cs b/src/SelfService/Infrastructure/Persistence/CapabilityRepository.cs index 0703bccd..9cf58f70 100644 --- a/src/SelfService/Infrastructure/Persistence/CapabilityRepository.cs +++ b/src/SelfService/Infrastructure/Persistence/CapabilityRepository.cs @@ -29,6 +29,16 @@ public async Task Get(CapabilityId id) return await _dbContext.Capabilities.FindAsync(id); } + public async Task> GetByIds(IEnumerable ids) + { + var idList = ids.Distinct().ToList(); + if (idList.Count == 0) + { + return new List(); + } + return await _dbContext.Capabilities.Where(c => idList.Contains(c.Id)).ToListAsync(); + } + public async Task Exists(CapabilityId id) { return await _dbContext.Capabilities.AnyAsync(x => x.Id == id); diff --git a/src/SelfService/Infrastructure/Persistence/MembershipRepository.cs b/src/SelfService/Infrastructure/Persistence/MembershipRepository.cs index bdce39d4..a355745b 100644 --- a/src/SelfService/Infrastructure/Persistence/MembershipRepository.cs +++ b/src/SelfService/Infrastructure/Persistence/MembershipRepository.cs @@ -57,4 +57,32 @@ public async Task> GetAllMembershipsForUserId(UserId userId) { return await GetAllWithPredicate(x => x.UserId == userId); } + + public async Task> GetAllMembershipsForUserIds(IEnumerable userIds) + { + var idList = userIds.Distinct().ToList(); + if (idList.Count == 0) + { + return new List(); + } + return await DbSetReference.Where(x => idList.Contains(x.UserId)).ToListAsync(); + } + + public async Task> GetMemberCountsByCapabilityIds( + IEnumerable capabilityIds + ) + { + var idList = capabilityIds.Distinct().ToList(); + if (idList.Count == 0) + { + return new Dictionary(); + } + // Project to the capability id column only, then group in memory — avoids relying on + // EF Core translating GroupBy over a value-converted key. + var capIds = await DbSetReference + .Where(x => idList.Contains(x.CapabilityId)) + .Select(x => x.CapabilityId) + .ToListAsync(); + return capIds.GroupBy(x => x).ToDictionary(g => g.Key, g => g.Count()); + } } diff --git a/src/SelfService/Infrastructure/Persistence/Queries/CapabilityMembersQuery.cs b/src/SelfService/Infrastructure/Persistence/Queries/CapabilityMembersQuery.cs index ba1f9e9b..d0f6b20a 100644 --- a/src/SelfService/Infrastructure/Persistence/Queries/CapabilityMembersQuery.cs +++ b/src/SelfService/Infrastructure/Persistence/Queries/CapabilityMembersQuery.cs @@ -183,4 +183,20 @@ public async Task> FindPendingBy(CapabilityId .OrderBy(x => x.SubmittedAt) .ToListAsync(); } + + public async Task> FindPendingByCapabilityIds( + IEnumerable capabilityIds + ) + { + var idList = capabilityIds.Distinct().ToList(); + if (idList.Count == 0) + { + return new List(); + } + return await _dbContext + .MembershipApplications.Where(x => + idList.Contains(x.CapabilityId) && x.Status == MembershipApplicationStatusOptions.PendingApprovals + ) + .ToListAsync(); + } } diff --git a/src/SelfService/Infrastructure/Persistence/RbacRoleGrantRepository.cs b/src/SelfService/Infrastructure/Persistence/RbacRoleGrantRepository.cs index cc9a82db..02d79767 100644 --- a/src/SelfService/Infrastructure/Persistence/RbacRoleGrantRepository.cs +++ b/src/SelfService/Infrastructure/Persistence/RbacRoleGrantRepository.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using SelfService.Domain.Models; namespace SelfService.Infrastructure.Persistence; @@ -6,4 +7,14 @@ public class RbacRoleGrantRepository : GenericRepository> GetByAssignedUsers(IReadOnlyCollection userIds) + { + if (userIds.Count == 0) + return Task.FromResult(new List()); + + return DbSetReference + .Where(g => g.AssignedEntityType == AssignedEntityType.User && userIds.Contains(g.AssignedEntityId)) + .ToListAsync(); + } } diff --git a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs index 9a4677b2..50f14a53 100644 --- a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs +++ b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs @@ -368,19 +368,17 @@ private string ExpandUserCapabilitiesBlocks(string template, TemplateRenderConte foreach (var entry in context.UserCapabilities) { // Inside the block, Capability.* resolves to this iteration's capability; - // Member, Campaign.Name, Date.* are inherited from the outer context. + // Member, Campaign.Name, Date.* are inherited from the outer context. The + // per-capability data (AWS, Azure, requirement scores, pending applications) + // is carried on the entry — bulk-loaded once per campaign by the caller. var subContext = context with { Capability = entry.Capability, MemberCount = entry.MemberCount, - // The remaining per-capability data (AWS, Azure, requirement scores, pending - // membership applications) is intentionally not loaded for the iteration. - // Doing so per-capability-per-recipient would be costly; if a campaign needs - // those, it should be Capability-targeted. - AwsAccount = null, - AzureResources = new(), - RequirementScores = new(), - PendingMembershipApplicationCount = 0, + AwsAccount = entry.AwsAccount, + AzureResources = entry.AzureResources, + RequirementScores = entry.RequirementScores, + PendingMembershipApplicationCount = entry.PendingMembershipApplicationCount, }; sb.Append(TokenRegex.Replace(body, m => Resolve(m.Groups[1].Value, subContext) ?? m.Value)); } @@ -405,17 +403,32 @@ private string ExpandUserCapabilitiesBlocks(string template, TemplateRenderConte return null; } - private static string? ResolveRequirementScore(TemplateRenderContext ctx, string id) => - ctx.RequirementScores.FirstOrDefault(s => s.RequirementId == id)?.Value.ToString("0"); + private static string? ResolveRequirementScore(TemplateRenderContext ctx, string id) + { + // Tags are not stored in the requirements DB — they are derived from the capability's metadata, + // matching how GET /compliance/capabilities/{id} computes the Tags score. + if (id == TagComplianceEvaluator.RequirementId) + return ctx.Capability is null + ? null + : TagComplianceEvaluator.Evaluate(ctx.Capability.JsonMetadata).Score.ToString("0"); + + return ctx.RequirementScores.FirstOrDefault(s => s.RequirementId == id)?.Value.ToString("0"); + } private static string? ResolveRequirementDisplayName(TemplateRenderContext ctx, string id) { + if (id == TagComplianceEvaluator.RequirementId) + return ctx.Capability is null ? null : TagComplianceEvaluator.DisplayName; + var metric = ctx.RequirementScores.FirstOrDefault(s => s.RequirementId == id); return metric is null ? null : (metric.DisplayName ?? metric.RequirementId); } private static string? ResolveRequirementHelpUrl(TemplateRenderContext ctx, string id) { + if (id == TagComplianceEvaluator.RequirementId) + return ctx.Capability is null ? null : TagComplianceEvaluator.HelpUrl; + var metric = ctx.RequirementScores.FirstOrDefault(s => s.RequirementId == id); return metric is null ? null : (metric.HelpUrl ?? ""); }