From 2e48883a0e24068532bb17bc9b1b2c9d90521982 Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Mon, 20 Jul 2026 12:58:16 +0200 Subject: [PATCH 1/3] add initial auth documentation --- doc/auth-and-access.md | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 doc/auth-and-access.md diff --git a/doc/auth-and-access.md b/doc/auth-and-access.md new file mode 100644 index 00000000..5549296e --- /dev/null +++ b/doc/auth-and-access.md @@ -0,0 +1,74 @@ +# Authentication, Authorization, and Access Permissions +This document describes how callers are authenticated in Self Service Universe, what they are authorized to do, and which parts are governed by RBAC versus external systems. + +Self Service Universe relies on Azure AD tokens, Azure AD metadata, and internal RBAC data. + +## Authentication +Self Service Universe uses Azure AD JWT validation for API authentication. + +All human users authenticate by signing in with Azure AD SSO. +The service also supports app/service-principal callers (non-human callers), which are mapped to an internal caller identity from token claims. + +Most controller endpoints require authentication by default. +There are currently anonymous service-catalog endpoints under /apispecs. + +## Authorization +Authorization is split into: + +- Global permissions (for example RBAC administration and system-level operations) +- Capability-scoped permissions (what can be done inside a specific capability) + +### Global Permissions +Cloud Engineer is determined from Azure AD role claims in the token. +Cloud Engineers have elevated access to many system-level functions. + +Important implementation detail: +Cloud Engineer is not a blanket bypass for all checks in all code paths. Many actions still depend on RBAC permission evaluation. + +### Capability Permissions +Capability permissions are controlled by RBAC roles and permissions. +Common assignable capability roles are Owner, Contributor, and Reader. + +Important implementation detail: +When a user has no explicit capability role grant, the system applies Guest permissions as an implicit fallback. +So "not a member" maps to Guest behavior by default, not Reader behavior by default. + +Owner semantics include preventing the last Owner from leaving a capability. +When a capability is created through normal flows, the creator is granted Owner. + +RBAC management UI: +https://ssu-preview.hellman.oxygen.dfds.cloud/admin/rbac + +#### Capability Permissions for Third Party Services +Third-party services (for example AWS or Confluent Cloud) are authorized through Azure AD groups and platform integrations. +Every Capability will have an Azure AD group for the Capability members. +If you are a member of this group, you will have access to the third party services connected to the capability. + +This access is separate from Self Service Universe RBAC. +In practice, access to external systems will depend on external group membership and provisioning state, not only on in-app role grants. + +### Cloud Engineers and Capability Permissions +Cloud Engineers generally have elevated capabilities and broad operational access. + +However, this should not be described as "always owner of all capabilities" in implementation terms. +For several operations, effective access is still granted through RBAC role/permission resolution. + + +## Known Issues and Operational Risks + +### Azure AD Group and User Management Risk +Because access is based on Azure AD tokens and metadata, privileged identity administration in Azure AD is a critical trust boundary. +Misconfiguration or malicious role assignment in Azure AD could grant excessive access. + +### Hard-Coded Accesses +There is a hard-coded allowlist for batch capability creation in addition to Cloud Engineer access. +This is operationally convenient but harder to audit and maintain than pure RBAC. + +### Middleware Kill-Switch Risk +The RBAC auth-check middleware can be disabled by environment variable. +If disabled, permission-attribute checks performed by that middleware are bypassed. +This should only be used intentionally and with strong operational controls. + +### Anonymous Endpoints +Service catalog endpoints under /apispecs are intentionally anonymous. +This is expected behavior but should remain explicit in threat modeling and external exposure reviews. \ No newline at end of file From 6ac65a08a4e0c8adca8ddbf875ef07871497c4b4 Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Mon, 27 Jul 2026 08:46:05 +0200 Subject: [PATCH 2/3] Update to match latest RBAC update --- doc/auth-and-access.md | 98 ++++++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 27 deletions(-) diff --git a/doc/auth-and-access.md b/doc/auth-and-access.md index 5549296e..f7f1f494 100644 --- a/doc/auth-and-access.md +++ b/doc/auth-and-access.md @@ -1,40 +1,60 @@ # Authentication, Authorization, and Access Permissions -This document describes how callers are authenticated in Self Service Universe, what they are authorized to do, and which parts are governed by RBAC versus external systems. +This document describes how callers are authenticated in Self Service Universe, how authorization is evaluated, and where Self Service RBAC stops and external platform access begins. -Self Service Universe relies on Azure AD tokens, Azure AD metadata, and internal RBAC data. +Self Service Universe uses Azure AD only for authentication. Authorization is defined in Self Service's own RBAC model, using internal roles, permissions, role grants, and RBAC groups. + +Authorization for usage of third party services still lies with Azure AD. ## Authentication Self Service Universe uses Azure AD JWT validation for API authentication. All human users authenticate by signing in with Azure AD SSO. -The service also supports app/service-principal callers (non-human callers), which are mapped to an internal caller identity from token claims. +The service also supports app or service-principal callers, which are mapped to an internal caller identity from token claims. -Most controller endpoints require authentication by default. -There are currently anonymous service-catalog endpoints under /apispecs. +Most endpoints require authentication by default. +There exists anonymous service-catalog endpoints under /apispecs. ## Authorization -Authorization is split into: +Authorization is evaluated by the internal RBAC system. + +The two main scopes are: + +- Global permissions for system-wide and administrative operations +- Capability-scoped permissions for actions inside a specific capability + +Both controller-level permission attributes and service-level authorization checks resolve through the RBAC application service. In practice, this means the source of truth for authorization is the RBAC data stored by Self Service, not Azure AD role metadata. + +### RBAC Building Blocks +The RBAC model is composed of: + +- Permissions such as create, read, update, delete, manage-requests, or request-deletion +- Namespaces that group permissions by domain, such as Topics, Aws, Azure, TagsAndMetadata, CapabilityMembershipManagement, and SystemAdmin +- Role grants that assign a role to a user or group for a resource +- Permission grants that assign permissions directly to a user, group, or role +- RBAC groups whose members inherit the role grants and permission grants assigned to those groups -- Global permissions (for example RBAC administration and system-level operations) -- Capability-scoped permissions (what can be done inside a specific capability) +For permission evaluation, Self Service combines: + +- Direct user grants +- Grants inherited through RBAC group membership +- Permissions implied by assigned roles ### Global Permissions -Cloud Engineer is determined from Azure AD role claims in the token. -Cloud Engineers have elevated access to many system-level functions. +Global permissions cover system-level operations such as RBAC administration and other administrative capabilities. -Important implementation detail: -Cloud Engineer is not a blanket bypass for all checks in all code paths. Many actions still depend on RBAC permission evaluation. +These permissions are evaluated in the same RBAC system as capability permissions. The relevant namespace for many of these checks is SystemAdmin, with some operations also using other global namespaces such as CapabilityManagement. ### Capability Permissions -Capability permissions are controlled by RBAC roles and permissions. -Common assignable capability roles are Owner, Contributor, and Reader. +Capability permissions are evaluated against the capability resource being accessed. + +Common assignable capability roles are Owner, Contributor, and Reader. Guest exists in the RBAC model, but it is not assignable. Instead, Guest is the implicit default role used when a user has no explicit capability role for that capability. Important implementation detail: -When a user has no explicit capability role grant, the system applies Guest permissions as an implicit fallback. -So "not a member" maps to Guest behavior by default, not Reader behavior by default. +"No explicit membership" does not mean "no access at all". If a capability-scoped check is performed and the user has no explicit capability role grant for that capability, Guest permissions are applied implicitly. -Owner semantics include preventing the last Owner from leaving a capability. -When a capability is created through normal flows, the creator is granted Owner. +Owner semantics still include protecting the last Owner on a capability. A user who is the last remaining Owner cannot leave that capability. + +When a capability is created through the normal membership application flow, the creator is granted the Owner role for that capability. RBAC management UI: https://ssu-preview.hellman.oxygen.dfds.cloud/admin/rbac @@ -47,28 +67,52 @@ If you are a member of this group, you will have access to the third party servi This access is separate from Self Service Universe RBAC. In practice, access to external systems will depend on external group membership and provisioning state, not only on in-app role grants. -### Cloud Engineers and Capability Permissions -Cloud Engineers generally have elevated capabilities and broad operational access. -However, this should not be described as "always owner of all capabilities" in implementation terms. -For several operations, effective access is still granted through RBAC role/permission resolution. +### RBAC Groups +RBAC groups are part of the authorization model inside Self Service. + +Users can receive effective permissions in two ways: + +- Directly, through grants assigned to the user +- Indirectly, through membership in one or more RBAC groups + +This replaces the earlier description where Azure AD metadata was treated as the source of role definitions. Azure AD still proves identity, but effective authorization is resolved from Self Service RBAC data. + +### Access to Third-Party Services +Access inside Self Service and access in external platforms are related but not identical. + +Self Service RBAC determines whether a caller can perform actions in the Self Service API and UI, for example requesting an AWS account, managing capability membership, or updating metadata. + +External platform access, such as access in AWS, Confluent Cloud, or other integrated systems, still depends on the provisioning and synchronization flows for those platforms. In other words, an in-app RBAC grant is not by itself a guarantee that external access has already been provisioned. + +## Important Nuances + +### Middleware and Service Checks +Most endpoint checks are enforced through RBAC permission attributes in middleware, and many domain operations also perform explicit authorization checks in the service layer. + +That layered approach is intentional. Authorization should be understood as a combination of middleware enforcement and domain-level checks, both backed by the same RBAC service. ## Known Issues and Operational Risks ### Azure AD Group and User Management Risk -Because access is based on Azure AD tokens and metadata, privileged identity administration in Azure AD is a critical trust boundary. +Because third party service access access is based on Azure AD groups, privileged identity administration in Azure AD is a critical trust boundary. Misconfiguration or malicious role assignment in Azure AD could grant excessive access. -### Hard-Coded Accesses -There is a hard-coded allowlist for batch capability creation in addition to Cloud Engineer access. -This is operationally convenient but harder to audit and maintain than pure RBAC. - ### Middleware Kill-Switch Risk The RBAC auth-check middleware can be disabled by environment variable. If disabled, permission-attribute checks performed by that middleware are bypassed. This should only be used intentionally and with strong operational controls. +### Guest Fallback Needs Careful Modeling +Because Guest permissions are applied implicitly when no explicit capability role exists, changes to the Guest permission set can affect non-members across the system. + +This is useful and intentional, but it also means Guest permissions are security-sensitive and should be reviewed carefully. + +### External Provisioning Drift +Self Service authorization and third-party platform authorization are not the same control plane. +If provisioning or synchronization is delayed or fails, a user may be authorized inside Self Service before equivalent access exists externally, or vice versa. + ### Anonymous Endpoints Service catalog endpoints under /apispecs are intentionally anonymous. This is expected behavior but should remain explicit in threat modeling and external exposure reviews. \ No newline at end of file From e6a659c38c0900cf0cde76f10448808e4d2c396a Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Thu, 27 Aug 2026 09:47:09 +0200 Subject: [PATCH 3/3] orphaned-capabilities-support --- .../TestComplianceApplicationService.cs | 75 +++++++++++++++++++ .../ComplianceApplicationServiceBuilder.cs | 23 +++++- .../ComplianceApplicationService.cs | 29 ++++++- .../IComplianceApplicationService.cs | 2 + .../StubComplianceApplicationService.cs | 31 +++++++- .../Api/Compliance/ComplianceController.cs | 14 ++++ 6 files changed, 168 insertions(+), 6 deletions(-) diff --git a/src/SelfService.Tests/Application/TestComplianceApplicationService.cs b/src/SelfService.Tests/Application/TestComplianceApplicationService.cs index e4bcb360..eb4f23ff 100644 --- a/src/SelfService.Tests/Application/TestComplianceApplicationService.cs +++ b/src/SelfService.Tests/Application/TestComplianceApplicationService.cs @@ -50,6 +50,20 @@ private static IAwsAccountRepository AwsAccountRepoWithK8sLinkFor(params Capabil return mock.Object; } + private static IMembershipRepository MembershipRepoWithCounts( + params (CapabilityId capabilityId, int count)[] counts + ) + { + var mock = new Mock(); + var countMap = counts.ToDictionary(x => x.capabilityId, x => x.count); + mock.Setup(r => r.GetMemberCountsByCapabilityIds(It.IsAny>())) + .ReturnsAsync( + (IEnumerable ids) => + ids.Distinct().Where(id => countMap.ContainsKey(id)).ToDictionary(id => id, id => countMap[id]) + ); + return mock.Object; + } + [Fact] public async Task GetCapabilityCompliance_AllTagsPresent_TagsCategoryCompliant() { @@ -512,6 +526,67 @@ public async Task GetRogueCapabilitiesComplianceDetails_MatchesAggregateCounts() Assert.Equal(aggregate.NonCompliantCount, details.NonCompliantCount); } + [Fact] + public async Task GetOrphanedCapabilitiesCompliance_FiltersCapabilitiesWithoutMembers() + { + var orphanedCap = A + .Capability.WithId(CapabilityId.CreateFrom("orphaned-cap")) + .WithJsonMetadata(AllTagsPresent) + .Build(); + var nonOrphanedCap = A + .Capability.WithId(CapabilityId.CreateFrom("non-orphaned-cap")) + .WithJsonMetadata(AllTagsPresent) + .Build(); + + var repo = new Mock(); + repo.Setup(r => r.GetAllActive()).ReturnsAsync(new[] { orphanedCap, nonOrphanedCap }); + + var membershipRepo = MembershipRepoWithCounts((nonOrphanedCap.Id, 2)); + + var service = A + .ComplianceApplicationService.WithCapabilityRepository(repo.Object) + .WithMembershipRepository(membershipRepo) + .Build(); + + var result = await service.GetOrphanedCapabilitiesCompliance(); + + Assert.Equal("orphaned", result.CostCentre); + Assert.Equal(1, result.TotalCapabilities); + } + + [Fact] + public async Task GetOrphanedCapabilitiesComplianceDetails_MatchesAggregateCounts() + { + var orphanedCap = A + .Capability.WithId(CapabilityId.CreateFrom("orphaned-cap")) + .WithJsonMetadata(AllTagsPresent) + .Build(); + var nonOrphanedCap = A + .Capability.WithId(CapabilityId.CreateFrom("non-orphaned-cap")) + .WithJsonMetadata(AllTagsPresent) + .Build(); + + var repo = new Mock(); + repo.Setup(r => r.GetAllActive()).ReturnsAsync(new[] { orphanedCap, nonOrphanedCap }); + + var membershipRepo = MembershipRepoWithCounts((nonOrphanedCap.Id, 1)); + + var service = A + .ComplianceApplicationService.WithCapabilityRepository(repo.Object) + .WithMembershipRepository(membershipRepo) + .Build(); + + var details = await service.GetOrphanedCapabilitiesComplianceDetails(); + var aggregate = await service.GetOrphanedCapabilitiesCompliance(); + + Assert.Equal("orphaned", details.CostCentre); + Assert.Single(details.Capabilities); + Assert.Equal(orphanedCap.Id.ToString(), details.Capabilities[0].CapabilityId); + Assert.Equal(aggregate.TotalCapabilities, details.TotalCapabilities); + Assert.Equal(aggregate.CompliantCount, details.CompliantCount); + Assert.Equal(aggregate.NonCompliantCount, details.NonCompliantCount); + } + [Fact] public async Task GetRequirementsCompliance_ReturnsKnownRequirementsAndCounts() { diff --git a/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs b/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs index 42e79911..3920705c 100644 --- a/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs +++ b/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs @@ -10,12 +10,14 @@ public class ComplianceApplicationServiceBuilder { private ICapabilityRepository _capabilityRepository; private IAwsAccountRepository _awsAccountRepository; + private IMembershipRepository _membershipRepository; private RequirementsDbContext? _requirementsDbContext; public ComplianceApplicationServiceBuilder() { _capabilityRepository = Dummy.Of(); _awsAccountRepository = DefaultAwsAccountRepository(); + _membershipRepository = DefaultMembershipRepository(); } public ComplianceApplicationServiceBuilder WithCapabilityRepository(ICapabilityRepository capabilityRepository) @@ -36,6 +38,12 @@ public ComplianceApplicationServiceBuilder WithRequirementsDbContext(Requirement return this; } + public ComplianceApplicationServiceBuilder WithMembershipRepository(IMembershipRepository membershipRepository) + { + _membershipRepository = membershipRepository; + return this; + } + public IComplianceApplicationService Build() { if (_requirementsDbContext != null) @@ -43,11 +51,16 @@ public IComplianceApplicationService Build() return new ComplianceApplicationService( _capabilityRepository, _awsAccountRepository, + _membershipRepository, _requirementsDbContext ); } - return new StubComplianceApplicationService(_capabilityRepository, _awsAccountRepository); + return new StubComplianceApplicationService( + _capabilityRepository, + _awsAccountRepository, + _membershipRepository + ); } private static IAwsAccountRepository DefaultAwsAccountRepository() @@ -58,4 +71,12 @@ private static IAwsAccountRepository DefaultAwsAccountRepository() .ReturnsAsync(new List()); return mock.Object; } + + private static IMembershipRepository DefaultMembershipRepository() + { + var mock = new Mock(); + mock.Setup(r => r.GetMemberCountsByCapabilityIds(It.IsAny>())) + .ReturnsAsync(new Dictionary()); + return mock.Object; + } } diff --git a/src/SelfService/Application/ComplianceApplicationService.cs b/src/SelfService/Application/ComplianceApplicationService.cs index 374f1179..ae708573 100644 --- a/src/SelfService/Application/ComplianceApplicationService.cs +++ b/src/SelfService/Application/ComplianceApplicationService.cs @@ -12,10 +12,12 @@ public class ComplianceApplicationService : IComplianceApplicationService { private readonly ICapabilityRepository _capabilityRepository; private readonly IAwsAccountRepository _awsAccountRepository; + private readonly IMembershipRepository _membershipRepository; private readonly RequirementsDbContext _requirementsDbContext; private static readonly string[] PlaceholderCategories = Array.Empty(); private const string RogueCostCentreName = "rogue"; + private const string OrphanedLabel = "orphaned"; private static readonly string[] Categories = { @@ -38,11 +40,13 @@ public class ComplianceApplicationService : IComplianceApplicationService public ComplianceApplicationService( ICapabilityRepository capabilityRepository, IAwsAccountRepository awsAccountRepository, + IMembershipRepository membershipRepository, RequirementsDbContext requirementsDbContext ) { _capabilityRepository = capabilityRepository; _awsAccountRepository = awsAccountRepository; + _membershipRepository = membershipRepository; _requirementsDbContext = requirementsDbContext; } @@ -114,6 +118,26 @@ public async Task GetRogueCapabilitiesComplia ); } + public async Task GetOrphanedCapabilitiesCompliance() + { + var details = await GetOrphanedCapabilitiesComplianceDetails(); + return ToCostCentreComplianceResult(details); + } + + public async Task GetOrphanedCapabilitiesComplianceDetails() + { + var activeCapabilities = (await _capabilityRepository.GetAllActive()).ToList(); + var memberCounts = await _membershipRepository.GetMemberCountsByCapabilityIds( + activeCapabilities.Select(c => c.Id) + ); + + return await BuildComplianceDetailsForCapabilities( + OrphanedLabel, + c => memberCounts.GetValueOrDefault(c.Id, 0) == 0, + activeCapabilities + ); + } + public async Task GetComplianceSummary() { var all = await BuildComplianceDetailsForCapabilities("all", _ => true); @@ -152,10 +176,11 @@ public async Task GetRequirementComplianceDe private async Task BuildComplianceDetailsForCapabilities( string costCentreLabel, - Func filter + Func filter, + List? activeCapabilities = null ) { - var activeCapabilities = await _capabilityRepository.GetAllActive(); + activeCapabilities ??= (await _capabilityRepository.GetAllActive()).ToList(); var matchingCapabilities = activeCapabilities.Where(filter).ToList(); diff --git a/src/SelfService/Application/IComplianceApplicationService.cs b/src/SelfService/Application/IComplianceApplicationService.cs index b29f5ef0..e3a0721f 100644 --- a/src/SelfService/Application/IComplianceApplicationService.cs +++ b/src/SelfService/Application/IComplianceApplicationService.cs @@ -10,6 +10,8 @@ public interface IComplianceApplicationService Task GetCostCentreComplianceDetails(string costCentre); Task GetRogueCapabilitiesCompliance(); Task GetRogueCapabilitiesComplianceDetails(); + Task GetOrphanedCapabilitiesCompliance(); + Task GetOrphanedCapabilitiesComplianceDetails(); Task GetRequirementsCompliance(); Task GetRequirementComplianceDetails(string requirementId); } diff --git a/src/SelfService/Application/StubComplianceApplicationService.cs b/src/SelfService/Application/StubComplianceApplicationService.cs index a90d9066..90a65a11 100644 --- a/src/SelfService/Application/StubComplianceApplicationService.cs +++ b/src/SelfService/Application/StubComplianceApplicationService.cs @@ -9,9 +9,11 @@ public class StubComplianceApplicationService : IComplianceApplicationService { private readonly ICapabilityRepository _capabilityRepository; private readonly IAwsAccountRepository _awsAccountRepository; + private readonly IMembershipRepository _membershipRepository; private static readonly string[] PlaceholderCategories = Array.Empty(); private const string RogueCostCentreName = "rogue"; + private const string OrphanedLabel = "orphaned"; private static readonly RequirementDefinition[] RequirementDefinitions = { @@ -24,11 +26,13 @@ public class StubComplianceApplicationService : IComplianceApplicationService public StubComplianceApplicationService( ICapabilityRepository capabilityRepository, - IAwsAccountRepository awsAccountRepository + IAwsAccountRepository awsAccountRepository, + IMembershipRepository membershipRepository ) { _capabilityRepository = capabilityRepository; _awsAccountRepository = awsAccountRepository; + _membershipRepository = membershipRepository; } public async Task GetCapabilityCompliance(CapabilityId capabilityId) @@ -143,6 +147,26 @@ public async Task GetRogueCapabilitiesComplia ); } + public async Task GetOrphanedCapabilitiesCompliance() + { + var details = await GetOrphanedCapabilitiesComplianceDetails(); + return ToCostCentreComplianceResult(details); + } + + public async Task GetOrphanedCapabilitiesComplianceDetails() + { + var activeCapabilities = (await _capabilityRepository.GetAllActive()).ToList(); + var memberCounts = await _membershipRepository.GetMemberCountsByCapabilityIds( + activeCapabilities.Select(c => c.Id) + ); + + return await BuildComplianceDetailsForCapabilities( + OrphanedLabel, + c => memberCounts.GetValueOrDefault(c.Id, 0) == 0, + activeCapabilities + ); + } + public async Task GetComplianceSummary() { var all = await BuildComplianceDetailsForCapabilities("all", _ => true); @@ -181,10 +205,11 @@ public async Task GetRequirementComplianceDe private async Task BuildComplianceDetailsForCapabilities( string costCentreLabel, - Func filter + Func filter, + List? activeCapabilities = null ) { - var activeCapabilities = await _capabilityRepository.GetAllActive(); + activeCapabilities ??= (await _capabilityRepository.GetAllActive()).ToList(); var matchingCapabilities = activeCapabilities.Where(filter).ToList(); diff --git a/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs b/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs index aba3e384..ea962a88 100644 --- a/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs +++ b/src/SelfService/Infrastructure/Api/Compliance/ComplianceController.cs @@ -67,6 +67,20 @@ public async Task GetRogueCapabilitiesComplianceDetails() return Ok(CostCentreComplianceDetailsApiResource.From(result)); } + [HttpGet("orphaned-capabilities")] + public async Task GetOrphanedCapabilitiesCompliance() + { + var result = await _complianceService.GetOrphanedCapabilitiesCompliance(); + return Ok(CostCentreComplianceApiResource.From(result)); + } + + [HttpGet("orphaned-capabilities/details")] + public async Task GetOrphanedCapabilitiesComplianceDetails() + { + var result = await _complianceService.GetOrphanedCapabilitiesComplianceDetails(); + return Ok(CostCentreComplianceDetailsApiResource.From(result)); + } + [HttpGet("requirements")] public async Task GetRequirementsCompliance() {