diff --git a/doc/auth-and-access.md b/doc/auth-and-access.md new file mode 100644 index 00000000..f7f1f494 --- /dev/null +++ b/doc/auth-and-access.md @@ -0,0 +1,118 @@ +# Authentication, Authorization, and Access Permissions +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 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 or service-principal callers, which are mapped to an internal caller identity from token claims. + +Most endpoints require authentication by default. +There exists anonymous service-catalog endpoints under /apispecs. + +## Authorization +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 + +For permission evaluation, Self Service combines: + +- Direct user grants +- Grants inherited through RBAC group membership +- Permissions implied by assigned roles + +### Global Permissions +Global permissions cover system-level operations such as RBAC administration and other administrative capabilities. + +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 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: +"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 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 + +#### 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. + + +### 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 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. + +### 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 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() {