diff --git a/src/SelfService.Tests/Application/TestComplianceApplicationService.cs b/src/SelfService.Tests/Application/TestComplianceApplicationService.cs index f24223a1..e4bcb360 100644 --- a/src/SelfService.Tests/Application/TestComplianceApplicationService.cs +++ b/src/SelfService.Tests/Application/TestComplianceApplicationService.cs @@ -511,4 +511,76 @@ public async Task GetRogueCapabilitiesComplianceDetails_MatchesAggregateCounts() Assert.Equal(aggregate.CompliantCount, details.CompliantCount); Assert.Equal(aggregate.NonCompliantCount, details.NonCompliantCount); } + + [Fact] + public async Task GetRequirementsCompliance_ReturnsKnownRequirementsAndCounts() + { + var k8sCapId = CapabilityId.CreateFrom("k8s-cap"); + var nonK8sCapId = CapabilityId.CreateFrom("non-k8s-cap"); + var k8sCap = A.Capability.WithId(k8sCapId).WithJsonMetadata(AllTagsPresent).Build(); + var nonK8sCap = A.Capability.WithId(nonK8sCapId).WithJsonMetadata(AllTagsPresent).Build(); + + var capabilityRepo = new Mock(); + capabilityRepo.Setup(r => r.GetAllActive()).ReturnsAsync(new[] { k8sCap, nonK8sCap }); + + var service = A + .ComplianceApplicationService.WithCapabilityRepository(capabilityRepo.Object) + .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(k8sCapId)) + .Build(); + + var result = await service.GetRequirementsCompliance(); + + Assert.Equal(5, result.Items.Count); + + var tags = result.Items.Single(i => i.RequirementId == "tags"); + Assert.Equal("Tags", tags.CategoryName); + Assert.Equal(2, tags.TotalCapabilities); + + var externalSecrets = result.Items.Single(i => i.RequirementId == "external-secrets"); + Assert.Equal("External Secrets", externalSecrets.CategoryName); + Assert.Equal(1, externalSecrets.TotalCapabilities); + } + + [Fact] + public async Task GetRequirementComplianceDetails_Tags_ReturnsCapabilityLevelData() + { + var compliantCap = A + .Capability.WithId(CapabilityId.CreateFrom("compliant-cap")) + .WithJsonMetadata(AllTagsPresent) + .Build(); + var nonCompliantCap = A + .Capability.WithId(CapabilityId.CreateFrom("non-compliant-cap")) + .WithJsonMetadata(EmptyMetadata) + .Build(); + + var capabilityRepo = new Mock(); + capabilityRepo.Setup(r => r.GetAllActive()).ReturnsAsync(new[] { compliantCap, nonCompliantCap }); + + var service = A.ComplianceApplicationService.WithCapabilityRepository(capabilityRepo.Object).Build(); + + var result = await service.GetRequirementComplianceDetails("tags"); + + Assert.Equal("tags", result.RequirementId); + Assert.Equal("Tags", result.CategoryName); + Assert.Equal(2, result.TotalCapabilities); + Assert.Equal(2, result.Capabilities.Count); + Assert.Contains(result.Capabilities, c => c.Status == ComplianceStatus.Compliant); + Assert.Contains(result.Capabilities, c => c.Status == ComplianceStatus.NonCompliant); + Assert.All(result.Capabilities, c => Assert.NotEmpty(c.Items)); + } + + [Fact] + public async Task GetRequirementComplianceDetails_UnknownRequirement_ThrowsKeyNotFoundException() + { + var capability = A.Capability.WithId(CapabilityId.CreateFrom("cap-1")).WithJsonMetadata(AllTagsPresent).Build(); + + var capabilityRepo = new Mock(); + capabilityRepo.Setup(r => r.GetAllActive()).ReturnsAsync(new[] { capability }); + + var service = A.ComplianceApplicationService.WithCapabilityRepository(capabilityRepo.Object).Build(); + + await Assert.ThrowsAsync( + () => service.GetRequirementComplianceDetails("not-a-real-requirement") + ); + } } diff --git a/src/SelfService/Application/ComplianceApplicationService.cs b/src/SelfService/Application/ComplianceApplicationService.cs index 2d12eb34..15a9a4fd 100644 --- a/src/SelfService/Application/ComplianceApplicationService.cs +++ b/src/SelfService/Application/ComplianceApplicationService.cs @@ -26,6 +26,15 @@ public class ComplianceApplicationService : IComplianceApplicationService "ECR pull policy", }; + private static readonly RequirementDefinition[] RequirementDefinitions = + { + new("tags", "Tags"), + new("external-secrets", "External Secrets"), + new("irsa-mutual-trust", "IRSA Mutual Trust"), + new("workload-liveness-and-readiness-probes", "Workload Liveness and Readiness Probes"), + new("ecr-pull-policy", "ECR pull policy"), + }; + public ComplianceApplicationService( ICapabilityRepository capabilityRepository, IAwsAccountRepository awsAccountRepository, @@ -105,6 +114,30 @@ public async Task GetRogueCapabilitiesComplia ); } + public async Task GetRequirementsCompliance() + { + var allCapabilitiesDetails = await BuildComplianceDetailsForCapabilities("all", _ => true); + + return new RequirementsComplianceResult + { + Items = RequirementDefinitions + .Select(definition => BuildRequirementSummary(definition, allCapabilitiesDetails.Capabilities)) + .ToList(), + }; + } + + public async Task GetRequirementComplianceDetails(string requirementId) + { + var definition = FindRequirementDefinition(requirementId); + if (definition == null) + { + throw new KeyNotFoundException($"Unknown compliance requirement id '{requirementId}'."); + } + + var allCapabilitiesDetails = await BuildComplianceDetailsForCapabilities("all", _ => true); + return BuildRequirementDetails(definition, allCapabilitiesDetails.Capabilities); + } + private async Task BuildComplianceDetailsForCapabilities( string costCentreLabel, Func filter @@ -198,6 +231,88 @@ private static CostCentreComplianceResult ToCostCentreComplianceResult(CostCentr }; } + private static RequirementComplianceSummaryResult BuildRequirementSummary( + RequirementDefinition definition, + List capabilities + ) + { + var categories = capabilities + .Select(capability => + capability.Categories.FirstOrDefault(category => category.CategoryName == definition.CategoryName) + ) + .Where(category => category != null) + .Select(category => category!) + .ToList(); + + var metadataSource = categories.FirstOrDefault(); + + return new RequirementComplianceSummaryResult + { + RequirementId = definition.RequirementId, + CategoryName = definition.CategoryName, + DisplayName = metadataSource?.DisplayName ?? definition.CategoryName, + Description = metadataSource?.Description, + HelpUrl = metadataSource?.HelpUrl, + TotalCapabilities = categories.Count, + CompliantCount = categories.Count(category => category.Status == ComplianceStatus.Compliant), + NonCompliantCount = categories.Count(category => category.Status == ComplianceStatus.NonCompliant), + UnknownCount = categories.Count(category => category.Status == ComplianceStatus.Unknown), + }; + } + + private static RequirementComplianceDetailsResult BuildRequirementDetails( + RequirementDefinition definition, + List capabilities + ) + { + var capabilityCategories = capabilities + .Select(capability => + { + var matchingCategory = capability.Categories.FirstOrDefault(category => + category.CategoryName == definition.CategoryName + ); + return new { capability, matchingCategory }; + }) + .Where(item => item.matchingCategory is not null) + .Select(item => new { item.capability, category = item.matchingCategory! }) + .ToList(); + + var metadataSource = capabilityCategories.Select(item => item.category).FirstOrDefault(); + + return new RequirementComplianceDetailsResult + { + RequirementId = definition.RequirementId, + CategoryName = definition.CategoryName, + DisplayName = metadataSource?.DisplayName ?? definition.CategoryName, + Description = metadataSource?.Description, + HelpUrl = metadataSource?.HelpUrl, + TotalCapabilities = capabilityCategories.Count, + CompliantCount = capabilityCategories.Count(item => item.category.Status == ComplianceStatus.Compliant), + NonCompliantCount = capabilityCategories.Count(item => + item.category.Status == ComplianceStatus.NonCompliant + ), + UnknownCount = capabilityCategories.Count(item => item.category.Status == ComplianceStatus.Unknown), + Capabilities = capabilityCategories + .Select(item => new RequirementCapabilityComplianceResult + { + CapabilityId = item.capability.CapabilityId, + CapabilityName = item.capability.CapabilityName, + JsonMetadata = item.capability.JsonMetadata, + Status = item.category.Status, + Score = item.category.Score, + Items = item.category.Items, + }) + .ToList(), + }; + } + + private static RequirementDefinition? FindRequirementDefinition(string requirementId) + { + return RequirementDefinitions.FirstOrDefault(definition => + string.Equals(definition.RequirementId, requirementId, StringComparison.OrdinalIgnoreCase) + ); + } + private static ComplianceCategoryResult CheckTagCompliance(string? jsonMetadata) { var evaluation = TagComplianceEvaluator.Evaluate(jsonMetadata); @@ -477,4 +592,6 @@ private static ComplianceStatus DetermineOverallStatus(List Categories { get; set; } = new(); } + +public class RequirementsComplianceResult +{ + public List Items { get; set; } = new(); +} + +public class RequirementComplianceSummaryResult +{ + public string RequirementId { get; set; } = null!; + public string CategoryName { get; set; } = null!; + public string? DisplayName { get; set; } + public string? Description { get; set; } + public string? HelpUrl { get; set; } + public int TotalCapabilities { get; set; } + public int CompliantCount { get; set; } + public int NonCompliantCount { get; set; } + public int UnknownCount { get; set; } +} + +public class RequirementComplianceDetailsResult : RequirementComplianceSummaryResult +{ + public List Capabilities { get; set; } = new(); +} + +public class RequirementCapabilityComplianceResult +{ + public string CapabilityId { get; set; } = null!; + public string CapabilityName { get; set; } = null!; + public string? JsonMetadata { get; set; } + public ComplianceStatus Status { get; set; } + public double? Score { get; set; } + public List Items { get; set; } = new(); +} diff --git a/src/SelfService/Application/IComplianceApplicationService.cs b/src/SelfService/Application/IComplianceApplicationService.cs index aff8a045..e5bd6692 100644 --- a/src/SelfService/Application/IComplianceApplicationService.cs +++ b/src/SelfService/Application/IComplianceApplicationService.cs @@ -9,4 +9,6 @@ public interface IComplianceApplicationService Task GetCostCentreComplianceDetails(string costCentre); Task GetRogueCapabilitiesCompliance(); Task GetRogueCapabilitiesComplianceDetails(); + Task GetRequirementsCompliance(); + Task GetRequirementComplianceDetails(string requirementId); } diff --git a/src/SelfService/Application/StubComplianceApplicationService.cs b/src/SelfService/Application/StubComplianceApplicationService.cs index 5b0a75d8..c6e68f47 100644 --- a/src/SelfService/Application/StubComplianceApplicationService.cs +++ b/src/SelfService/Application/StubComplianceApplicationService.cs @@ -13,6 +13,15 @@ public class StubComplianceApplicationService : IComplianceApplicationService private static readonly string[] PlaceholderCategories = Array.Empty(); private const string RogueCostCentreName = "rogue"; + private static readonly RequirementDefinition[] RequirementDefinitions = + { + new("tags", "Tags"), + new("external-secrets", "External Secrets"), + new("irsa-mutual-trust", "IRSA Mutual Trust"), + new("workload-liveness-and-readiness-probes", "Workload Liveness and Readiness Probes"), + new("ecr-pull-policy", "ECR pull policy"), + }; + public StubComplianceApplicationService( ICapabilityRepository capabilityRepository, IAwsAccountRepository awsAccountRepository @@ -134,6 +143,30 @@ public async Task GetRogueCapabilitiesComplia ); } + public async Task GetRequirementsCompliance() + { + var allCapabilitiesDetails = await BuildComplianceDetailsForCapabilities("all", _ => true); + + return new RequirementsComplianceResult + { + Items = RequirementDefinitions + .Select(definition => BuildRequirementSummary(definition, allCapabilitiesDetails.Capabilities)) + .ToList(), + }; + } + + public async Task GetRequirementComplianceDetails(string requirementId) + { + var definition = FindRequirementDefinition(requirementId); + if (definition == null) + { + throw new KeyNotFoundException($"Unknown compliance requirement id '{requirementId}'."); + } + + var allCapabilitiesDetails = await BuildComplianceDetailsForCapabilities("all", _ => true); + return BuildRequirementDetails(definition, allCapabilitiesDetails.Capabilities); + } + private async Task BuildComplianceDetailsForCapabilities( string costCentreLabel, Func filter @@ -253,6 +286,88 @@ private static CostCentreComplianceResult ToCostCentreComplianceResult(CostCentr }; } + private static RequirementComplianceSummaryResult BuildRequirementSummary( + RequirementDefinition definition, + List capabilities + ) + { + var categories = capabilities + .Select(capability => + capability.Categories.FirstOrDefault(category => category.CategoryName == definition.CategoryName) + ) + .Where(category => category != null) + .Select(category => category!) + .ToList(); + + var metadataSource = categories.FirstOrDefault(); + + return new RequirementComplianceSummaryResult + { + RequirementId = definition.RequirementId, + CategoryName = definition.CategoryName, + DisplayName = metadataSource?.DisplayName ?? definition.CategoryName, + Description = metadataSource?.Description, + HelpUrl = metadataSource?.HelpUrl, + TotalCapabilities = categories.Count, + CompliantCount = categories.Count(category => category.Status == ComplianceStatus.Compliant), + NonCompliantCount = categories.Count(category => category.Status == ComplianceStatus.NonCompliant), + UnknownCount = categories.Count(category => category.Status == ComplianceStatus.Unknown), + }; + } + + private static RequirementComplianceDetailsResult BuildRequirementDetails( + RequirementDefinition definition, + List capabilities + ) + { + var capabilityCategories = capabilities + .Select(capability => + { + var matchingCategory = capability.Categories.FirstOrDefault(category => + category.CategoryName == definition.CategoryName + ); + return new { capability, matchingCategory }; + }) + .Where(item => item.matchingCategory is not null) + .Select(item => new { item.capability, category = item.matchingCategory! }) + .ToList(); + + var metadataSource = capabilityCategories.Select(item => item.category).FirstOrDefault(); + + return new RequirementComplianceDetailsResult + { + RequirementId = definition.RequirementId, + CategoryName = definition.CategoryName, + DisplayName = metadataSource?.DisplayName ?? definition.CategoryName, + Description = metadataSource?.Description, + HelpUrl = metadataSource?.HelpUrl, + TotalCapabilities = capabilityCategories.Count, + CompliantCount = capabilityCategories.Count(item => item.category.Status == ComplianceStatus.Compliant), + NonCompliantCount = capabilityCategories.Count(item => + item.category.Status == ComplianceStatus.NonCompliant + ), + UnknownCount = capabilityCategories.Count(item => item.category.Status == ComplianceStatus.Unknown), + Capabilities = capabilityCategories + .Select(item => new RequirementCapabilityComplianceResult + { + CapabilityId = item.capability.CapabilityId, + CapabilityName = item.capability.CapabilityName, + JsonMetadata = item.capability.JsonMetadata, + Status = item.category.Status, + Score = item.category.Score, + Items = item.category.Items, + }) + .ToList(), + }; + } + + private static RequirementDefinition? FindRequirementDefinition(string requirementId) + { + return RequirementDefinitions.FirstOrDefault(definition => + string.Equals(definition.RequirementId, requirementId, StringComparison.OrdinalIgnoreCase) + ); + } + private async Task HasKubernetesContext(CapabilityId capabilityId) { var awsAccount = await _awsAccountRepository.FindBy(capabilityId); @@ -289,4 +404,6 @@ private static ComplianceCategoryResult CheckTagCompliance(string? jsonMetadata) var jsonObject = JsonNode.Parse(jsonMetadata)?.AsObject(); return jsonObject?["dfds.cost.centre"]?.ToString(); } + + private sealed record RequirementDefinition(string RequirementId, string CategoryName); } diff --git a/src/SelfService/Infrastructure/Api/Compliance/ComplianceApiResources.cs b/src/SelfService/Infrastructure/Api/Compliance/ComplianceApiResources.cs index 640d25f8..1be74af7 100644 --- a/src/SelfService/Infrastructure/Api/Compliance/ComplianceApiResources.cs +++ b/src/SelfService/Infrastructure/Api/Compliance/ComplianceApiResources.cs @@ -146,3 +146,99 @@ public static CostCentreCapabilityComplianceApiResource From(CostCentreCapabilit }; } } + +public class RequirementsComplianceApiResource +{ + public List Items { get; set; } = new(); + + public static RequirementsComplianceApiResource From(RequirementsComplianceResult result) + { + return new RequirementsComplianceApiResource + { + Items = result.Items.Select(RequirementComplianceSummaryApiResource.From).ToList(), + }; + } +} + +public class RequirementComplianceSummaryApiResource +{ + public string RequirementId { get; set; } = null!; + public string CategoryName { get; set; } = null!; + public string? DisplayName { get; set; } + public string? Description { get; set; } + public string? HelpUrl { get; set; } + public int TotalCapabilities { get; set; } + public int CompliantCount { get; set; } + public int NonCompliantCount { get; set; } + public int UnknownCount { get; set; } + + public static RequirementComplianceSummaryApiResource From(RequirementComplianceSummaryResult result) + { + return new RequirementComplianceSummaryApiResource + { + RequirementId = result.RequirementId, + CategoryName = result.CategoryName, + DisplayName = result.DisplayName, + Description = result.Description, + HelpUrl = result.HelpUrl, + TotalCapabilities = result.TotalCapabilities, + CompliantCount = result.CompliantCount, + NonCompliantCount = result.NonCompliantCount, + UnknownCount = result.UnknownCount, + }; + } +} + +public class RequirementComplianceDetailsApiResource +{ + public string RequirementId { get; set; } = null!; + public string CategoryName { get; set; } = null!; + public string? DisplayName { get; set; } + public string? Description { get; set; } + public string? HelpUrl { get; set; } + public int TotalCapabilities { get; set; } + public int CompliantCount { get; set; } + public int NonCompliantCount { get; set; } + public int UnknownCount { get; set; } + public List Capabilities { get; set; } = new(); + + public static RequirementComplianceDetailsApiResource From(RequirementComplianceDetailsResult result) + { + return new RequirementComplianceDetailsApiResource + { + RequirementId = result.RequirementId, + CategoryName = result.CategoryName, + DisplayName = result.DisplayName, + Description = result.Description, + HelpUrl = result.HelpUrl, + TotalCapabilities = result.TotalCapabilities, + CompliantCount = result.CompliantCount, + NonCompliantCount = result.NonCompliantCount, + UnknownCount = result.UnknownCount, + Capabilities = result.Capabilities.Select(RequirementCapabilityComplianceApiResource.From).ToList(), + }; + } +} + +public class RequirementCapabilityComplianceApiResource +{ + public string CapabilityId { get; set; } = null!; + public string CapabilityName { get; set; } = null!; + public string? JsonMetadata { get; set; } + public string Status { get; set; } = null!; + public double? Score { get; set; } + public List Items { get; set; } = new(); + + public static RequirementCapabilityComplianceApiResource From(RequirementCapabilityComplianceResult result) + { + return new RequirementCapabilityComplianceApiResource + { + CapabilityId = result.CapabilityId, + CapabilityName = result.CapabilityName, + JsonMetadata = result.JsonMetadata, + Status = result.Status.ToString(), + Score = result.Score, + Items = result.Items.Select(ComplianceCategoryItemApiResource.From).ToList(), + }; + } +} diff --git a/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs b/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs index 02acbf33..2669e546 100644 --- a/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs +++ b/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs @@ -59,4 +59,25 @@ public async Task GetRogueCapabilitiesComplianceDetails() var result = await _complianceService.GetRogueCapabilitiesComplianceDetails(); return Ok(CostCentreComplianceDetailsApiResource.From(result)); } + + [HttpGet("requirements")] + public async Task GetRequirementsCompliance() + { + var result = await _complianceService.GetRequirementsCompliance(); + return Ok(RequirementsComplianceApiResource.From(result)); + } + + [HttpGet("requirements/{id}")] + public async Task GetRequirementComplianceDetails([FromRoute] string id) + { + try + { + var result = await _complianceService.GetRequirementComplianceDetails(id); + return Ok(RequirementComplianceDetailsApiResource.From(result)); + } + catch (KeyNotFoundException) + { + return NotFound(); + } + } }