From cebb7934615ac51167dbc1df8f25dd9d2cdeb8cf Mon Sep 17 00:00:00 2001 From: Farshad DASHTI Date: Wed, 6 Nov 2024 14:48:35 +0000 Subject: [PATCH] Added Security package (%release-note: - Added DfE.CoreLibs.Security, including Authorization extension which faciliates creation of Policies, Roles and Claims %) --- .github/workflows/build-test-security.yml | 22 +++ .github/workflows/pack-security.yml | 16 ++ DfE.CoreLibs.sln | 13 ++ .../Authorization/AuthorizationExtensions.cs | 123 ++++++++++++ .../CustomClaimsTransformation.cs | 37 ++++ .../Configurations/ClaimDefinition.cs | 8 + .../Configurations/PolicyDefinition.cs | 15 ++ .../DfE.CoreLibs.Security.csproj | 30 +++ .../ICustomAuthorizationRequirement.cs | 12 ++ .../Interfaces/ICustomClaimProvider.cs | 17 ++ src/DfE.CoreLibs.Security/readme.md | 181 ++++++++++++++++++ .../AuthorizationExtensionsTests.cs | 166 ++++++++++++++++ .../CustomClaimsTransformationTests.cs | 50 +++++ .../DfE.CoreLibs.Security.Tests.csproj | 34 ++++ .../appsettings.json | 37 ++++ 15 files changed, 761 insertions(+) create mode 100644 .github/workflows/build-test-security.yml create mode 100644 .github/workflows/pack-security.yml create mode 100644 src/DfE.CoreLibs.Security/Authorization/AuthorizationExtensions.cs create mode 100644 src/DfE.CoreLibs.Security/Authorization/CustomClaimsTransformation.cs create mode 100644 src/DfE.CoreLibs.Security/Configurations/ClaimDefinition.cs create mode 100644 src/DfE.CoreLibs.Security/Configurations/PolicyDefinition.cs create mode 100644 src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj create mode 100644 src/DfE.CoreLibs.Security/Interfaces/ICustomAuthorizationRequirement.cs create mode 100644 src/DfE.CoreLibs.Security/Interfaces/ICustomClaimProvider.cs create mode 100644 src/DfE.CoreLibs.Security/readme.md create mode 100644 src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs create mode 100644 src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/CustomClaimsTransformationTests.cs create mode 100644 src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj create mode 100644 src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json diff --git a/.github/workflows/build-test-security.yml b/.github/workflows/build-test-security.yml new file mode 100644 index 0000000..aa3f1de --- /dev/null +++ b/.github/workflows/build-test-security.yml @@ -0,0 +1,22 @@ +name: Build DfE.CoreLibs.Security + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Security/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Security/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.Security + project_path: src/DfE.CoreLibs.Security + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pack-security.yml b/.github/workflows/pack-security.yml new file mode 100644 index 0000000..1e1bf3b --- /dev/null +++ b/.github/workflows/pack-security.yml @@ -0,0 +1,16 @@ +name: Pack DfE.CoreLibs.Security + +on: + workflow_run: + workflows: ["Build DfE.CoreLibs.Security"] + types: + - completed + +jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.Security + project_path: src/DfE.CoreLibs.Security + nuget_package_name: DfE.CoreLibs.Security diff --git a/DfE.CoreLibs.sln b/DfE.CoreLibs.sln index 13f5661..2c837b7 100644 --- a/DfE.CoreLibs.sln +++ b/DfE.CoreLibs.sln @@ -25,6 +25,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Caching.Tests" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.AsyncProcessing.Tests", "src\Tests\DfE.CoreLibs.AsyncProcessing.Tests\DfE.CoreLibs.AsyncProcessing.Tests.csproj", "{5ABF8802-0C35-42D3-B2BB-83BD7159124F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Security", "src\DfE.CoreLibs.Security\DfE.CoreLibs.Security.csproj", "{374F44BE-DAC1-4974-BCA8-06ADB72C9BF0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DfE.CoreLibs.Security.Tests", "src\Tests\DfE.CoreLibs.Security.Tests\DfE.CoreLibs.Security.Tests.csproj", "{45F6BF60-A2AC-4084-AB62-1CAD172E95AE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +75,14 @@ Global {5ABF8802-0C35-42D3-B2BB-83BD7159124F}.Debug|Any CPU.Build.0 = Debug|Any CPU {5ABF8802-0C35-42D3-B2BB-83BD7159124F}.Release|Any CPU.ActiveCfg = Release|Any CPU {5ABF8802-0C35-42D3-B2BB-83BD7159124F}.Release|Any CPU.Build.0 = Release|Any CPU + {374F44BE-DAC1-4974-BCA8-06ADB72C9BF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {374F44BE-DAC1-4974-BCA8-06ADB72C9BF0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {374F44BE-DAC1-4974-BCA8-06ADB72C9BF0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {374F44BE-DAC1-4974-BCA8-06ADB72C9BF0}.Release|Any CPU.Build.0 = Release|Any CPU + {45F6BF60-A2AC-4084-AB62-1CAD172E95AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45F6BF60-A2AC-4084-AB62-1CAD172E95AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45F6BF60-A2AC-4084-AB62-1CAD172E95AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45F6BF60-A2AC-4084-AB62-1CAD172E95AE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -80,6 +92,7 @@ Global {69529D73-DD34-43A2-9D06-F3783F68F05C} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} {807147EB-9B76-42F6-B249-A0F0CF3C3462} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} {5ABF8802-0C35-42D3-B2BB-83BD7159124F} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} + {45F6BF60-A2AC-4084-AB62-1CAD172E95AE} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {01D11FBC-6C66-43E4-8F1F-46B105EDD95C} diff --git a/src/DfE.CoreLibs.Security/Authorization/AuthorizationExtensions.cs b/src/DfE.CoreLibs.Security/Authorization/AuthorizationExtensions.cs new file mode 100644 index 0000000..2761d6e --- /dev/null +++ b/src/DfE.CoreLibs.Security/Authorization/AuthorizationExtensions.cs @@ -0,0 +1,123 @@ +using DfE.CoreLibs.Security.Configurations; +using DfE.CoreLibs.Security.Interfaces; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DfE.CoreLibs.Security.Authorization +{ + /// + /// Extension methods for configuring application authorization and custom claim providers. + /// + public static class AuthorizationExtensions + { + /// + /// Configures authorization policies based on roles, claims, and custom requirements. + /// Loads policies from the provided configuration. + /// + /// The service collection to add authorization services to. + /// The configuration object containing policy definitions. + /// The the customizations such as Requirements which can be added to the policy after it is created. + /// The modified service collection. + public static IServiceCollection AddApplicationAuthorization( + this IServiceCollection services, + IConfiguration configuration, + Dictionary>? policyCustomizations = null) + { + services.AddAuthorization(options => + { + var policies = configuration.GetSection("Authorization:Policies").Get>(); + + foreach (var policyConfig in policies ?? []) + { + options.AddPolicy(policyConfig.Name, policyBuilder => + { + policyBuilder.RequireAuthenticatedUser(); + + if (policyConfig.Roles.Any()) + { + if (string.Equals(policyConfig.Operator, "AND", StringComparison.OrdinalIgnoreCase)) + { + // Use AND logic: require each role individually + foreach (var role in policyConfig.Roles) + { + policyBuilder.RequireRole(role); + } + } + else + { + // Use OR logic: require any of the roles + policyBuilder.RequireRole(policyConfig.Roles.ToArray()); + } + } + + if (policyConfig.Claims != null && policyConfig.Claims.Any()) + { + foreach (var claim in policyConfig.Claims) + { + policyBuilder.RequireClaim(claim.Type, claim.Values.ToArray()); + } + } + }); + } + + if (policyCustomizations != null) + { + foreach (var (policyName, customization) in policyCustomizations) + { + if (options.GetPolicy(policyName) is not null) + { + // If the policy already exists, modify it + UpdateExistingPolicy(options, policyName, customization); + } + else + { + // If the policy does not exist, create a new one + options.AddPolicy(policyName, customization); + } + } + } + }); + + services.AddTransient(); + + return services; + } + + /// + /// Registers a custom claim provider to retrieve claims dynamically. + /// + /// The custom claim provider implementing ICustomClaimProvider. + /// The service collection to add the claim provider to. + /// The modified service collection. + public static IServiceCollection AddCustomClaimProvider(this IServiceCollection services) + where TProvider : class, ICustomClaimProvider + { + services.AddTransient(); + return services; + } + + /// + /// Updates an existing policy with additional requirements from a customization action. + /// + private static void UpdateExistingPolicy(AuthorizationOptions options, string policyName, Action customization) + { + var existingPolicyBuilder = new AuthorizationPolicyBuilder(); + + // Copy existing policy requirements + var existingPolicy = options.GetPolicy(policyName)!; + foreach (var requirement in existingPolicy.Requirements) + { + existingPolicyBuilder.Requirements.Add(requirement); + } + + // Apply the new customization + customization(existingPolicyBuilder); + + // Replace the policy with the updated one + options.AddPolicy(policyName, existingPolicyBuilder.Build()); + } + + } +} diff --git a/src/DfE.CoreLibs.Security/Authorization/CustomClaimsTransformation.cs b/src/DfE.CoreLibs.Security/Authorization/CustomClaimsTransformation.cs new file mode 100644 index 0000000..c52bdb2 --- /dev/null +++ b/src/DfE.CoreLibs.Security/Authorization/CustomClaimsTransformation.cs @@ -0,0 +1,37 @@ +using DfE.CoreLibs.Security.Interfaces; +using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; + +namespace DfE.CoreLibs.Security.Authorization +{ + /// + /// Transforms the claims of the current user by adding custom claims from registered claim providers. + /// + public class CustomClaimsTransformation(IEnumerable claimProviders) : IClaimsTransformation + { + /// + /// Transforms the user's claims by adding custom claims retrieved from each registered claim provider. + /// + /// The current user's ClaimsPrincipal. + /// The modified ClaimsPrincipal with additional claims. + public async Task TransformAsync(ClaimsPrincipal principal) + { + var identity = (ClaimsIdentity)principal.Identity!; + + foreach (var provider in claimProviders) + { + var claims = await provider.GetClaimsAsync(principal); + + foreach (var claim in claims) + { + if (!identity.HasClaim(c => c.Type == claim.Type)) + { + identity.AddClaim(claim); + } + } + } + + return principal; + } + } +} diff --git a/src/DfE.CoreLibs.Security/Configurations/ClaimDefinition.cs b/src/DfE.CoreLibs.Security/Configurations/ClaimDefinition.cs new file mode 100644 index 0000000..35273ed --- /dev/null +++ b/src/DfE.CoreLibs.Security/Configurations/ClaimDefinition.cs @@ -0,0 +1,8 @@ +namespace DfE.DomainDrivenDesignTemplate.Infrastructure.Security.Configurations +{ + public class ClaimDefinition + { + public required string Type { get; set; } + public required List Values { get; set; } + } +} diff --git a/src/DfE.CoreLibs.Security/Configurations/PolicyDefinition.cs b/src/DfE.CoreLibs.Security/Configurations/PolicyDefinition.cs new file mode 100644 index 0000000..7a1fc87 --- /dev/null +++ b/src/DfE.CoreLibs.Security/Configurations/PolicyDefinition.cs @@ -0,0 +1,15 @@ +using DfE.DomainDrivenDesignTemplate.Infrastructure.Security.Configurations; + +namespace DfE.CoreLibs.Security.Configurations +{ + /// + /// Represents a policy definition, including roles, claims, and custom requirements. + /// + public class PolicyDefinition + { + public required string Name { get; set; } + public required string Operator { get; set; } = "OR"; // "AND" or "OR" + public required List Roles { get; set; } + public List? Claims { get; set; } + } +} diff --git a/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj new file mode 100644 index 0000000..1da3d1c --- /dev/null +++ b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + readme.md + DfE.CoreLibs.Security + A library providing flexible foundation for managing security in .NET projects, including role-based and claim-based policies, custom requirements, and dynamic claims. It enables consistent, configurable security across applications. + + DFE-Digital + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/DfE.CoreLibs.Security/Interfaces/ICustomAuthorizationRequirement.cs b/src/DfE.CoreLibs.Security/Interfaces/ICustomAuthorizationRequirement.cs new file mode 100644 index 0000000..2392f70 --- /dev/null +++ b/src/DfE.CoreLibs.Security/Interfaces/ICustomAuthorizationRequirement.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; + +namespace DfE.CoreLibs.Security.Interfaces +{ + /// + /// Represents a custom authorization requirement with a unique type. + /// + public interface ICustomAuthorizationRequirement : IAuthorizationRequirement + { + string Type { get; } + } +} diff --git a/src/DfE.CoreLibs.Security/Interfaces/ICustomClaimProvider.cs b/src/DfE.CoreLibs.Security/Interfaces/ICustomClaimProvider.cs new file mode 100644 index 0000000..b147d05 --- /dev/null +++ b/src/DfE.CoreLibs.Security/Interfaces/ICustomClaimProvider.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; + +namespace DfE.CoreLibs.Security.Interfaces +{ + /// + /// Interface for custom claim providers that retrieve claims for a user. + /// + public interface ICustomClaimProvider + { + /// + /// Asynchronously retrieves claims for the specified user. + /// + /// The current user's ClaimsPrincipal. + /// A list of claims to add to the user's identity. + Task> GetClaimsAsync(ClaimsPrincipal principal); + } +} diff --git a/src/DfE.CoreLibs.Security/readme.md b/src/DfE.CoreLibs.Security/readme.md new file mode 100644 index 0000000..00ea8d7 --- /dev/null +++ b/src/DfE.CoreLibs.Security/readme.md @@ -0,0 +1,181 @@ +# DfE.CoreLibs.Security + +The DfE.CoreLibs.Security library provides a flexible foundation for managing security in .NET projects, including role-based and claim-based policies, custom requirements, and dynamic claims. It enables consistent, configurable security across applications. + +## Installation + +To install the DfE.CoreLibs.Security library, use the following command in your .NET project: + + + dotnet add package DfE.CoreLibs.Security + + +## Usage + +### Setting Up Authorization Policies and Claims + +#### 1\. Service Registration + +Use the `AddApplicationAuthorization` extension method to register authorization policies and configure custom claims and requirements. Policies can be defined in the `appsettings.json` file or programmatically, and you can add claim providers to inject claims dynamically. + +Here's how to set up the service in `ConfigureServices`: + + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + services.AddApplicationAuthorization(configuration); + services.AddCustomClaimProvider(); // if you need to add custom/ non-security claims +} +``` + + +#### 2\. Configuring Policies and Claims in `appsettings.json` + +Define your authorization policies in `appsettings.json` under the `Authorization:Policies` section. Each policy can specify required roles, claims, and custom requirements. + +**Example configuration:** + + +```json +{ + "Authorization": { + "Policies": [ + { + "Name": "CanRead", + "Operator": "OR", + "Roles": [ "API.Read" ] + }, + { + "Name": "CanReadWrite", + "Operator": "AND", + "Roles": [ "API.Read", "API.Write" ] + }, + { + "Name": "CanReadWritePlus", + "Operator": "AND", + "Roles": [ "API.Read", "API.Write" ], + "Claims": [ + { + "Type": "API.PersonalInfo", + "Values": [ "true" ] + } + ] + } + ] + } +} +``` + + +#### 3\. Using Policy Customization to add a new Requirement + +To create custom requirements, implement the `ICustomAuthorizationRequirement` interface and register them using the `RequirementRegistry`. + +For example, let's define a `LocationAccessRequirement` that restricts access to users from a specified location. + +##### a. Define the `LocationAccessRequirement` class + + +```csharp +public class LocationAccessRequirement : ICustomAuthorizationRequirement +{ + public string Type => "LocationAccess"; + public string RequiredLocation { get; } + + public LocationAccessRequirement(string requiredLocation) + { + RequiredLocation = requiredLocation; + } +} +``` + +##### b. Create a Handler for the Requirement + + +```csharp +public class LocationAccessHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocationAccessRequirement requirement) + { + // Check if the user has a Location claim that matches the required location + var userLocation = context.User.FindFirst("Location")?.Value; + if (userLocation == requirement.RequiredLocation) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} +``` + + ##### c. Register and add the Requirement and Handler in `Startup.cs`, the Key in the `Dictionary>` is name of the Policy you are adding the Requirement to. If the Policy doesnt exist then a new Policy with this Requirement is created for you. + + +```csharp + services.AddApplicationAuthorization(configuration, new Dictionary> + { + { "LocationAccess", policy => + { + policy.Requirements.Add(new LocationAccessRequirement("Headquarters")); + } + } + }); + + services.AddSingleton(); +``` + + +#### 4\. Adding Custom Claim Providers + +Custom claim providers allow you to fetch claims dynamically based on the user’s identity. Implement `ICustomClaimProvider` to create a custom claim provider and register it in `Startup.cs`. + + +```csharp +public class UserProfileClaimProvider : ICustomClaimProvider +{ + public Task> GetClaimsAsync(ClaimsPrincipal principal) + { + var claims = new List + { + new Claim("FirstName", "John"), + new Claim("PhoneNumber", "+123456789"), + new Claim("Location", "Headquarters") + }; + return Task.FromResult((IEnumerable)claims); + } +} + +services.AddCustomClaimProvider(); + +``` + + +#### 5\. Applying Policies in Controllers + +Once configured, use `[Authorize(Policy = "PolicyName")]` to apply policies on controllers or specific actions. + + +```csharp +[Authorize(Policy = "AdminOnly")] +public class AdminController : Controller +{ + public IActionResult Dashboard() => View(); +} + +[Authorize(Policy = "LocationAccess")] +public IActionResult HeadquartersContent() => View(); + +``` + +* * * + + +### Summary + +* **Flexible Policy Configuration:** Define policies in `appsettings.json`. +* **Custom Claim Support:** Add dynamic claims to users based on identity, allowing additional user-specific data without modifying core claims. +* **Custom Requirement Registry:** Register and configure custom requirements dynamically to handle complex security rules. + +This setup allows flexible and maintainable authorization control across your .NET application, supporting diverse security needs without hard-coded rules. \ No newline at end of file diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs new file mode 100644 index 0000000..0c52e05 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/AuthorizationExtensionsTests.cs @@ -0,0 +1,166 @@ +using DfE.CoreLibs.Security.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DfE.CoreLibs.Security.Tests.AuthorizationTests +{ + public class AuthorizationExtensionsTests + { + private readonly IConfiguration _configuration; + + // Constructor to initialize shared configuration + public AuthorizationExtensionsTests() + { + _configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + } + + [Fact] + public void AddApplicationAuthorization_ShouldLoadPoliciesFromConfiguration() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddApplicationAuthorization(_configuration); + + var provider = services.BuildServiceProvider(); + var authorizationOptions = provider.GetRequiredService() as DefaultAuthorizationPolicyProvider; + + // Assert that policies are loaded correctly + Assert.NotNull(authorizationOptions); + + // Check each policy + var canReadPolicy = authorizationOptions!.GetPolicyAsync("CanRead").Result; + Assert.NotNull(canReadPolicy); + Assert.Contains(canReadPolicy.Requirements, r => r is RolesAuthorizationRequirement); + + var canReadWritePolicy = authorizationOptions.GetPolicyAsync("CanReadWrite").Result; + Assert.NotNull(canReadWritePolicy); + Assert.Contains(canReadWritePolicy.Requirements, r => r is RolesAuthorizationRequirement); + + var canReadWritePlusPolicy = authorizationOptions.GetPolicyAsync("CanReadWritePlus").Result; + Assert.NotNull(canReadWritePlusPolicy); + Assert.Contains(canReadWritePlusPolicy.Requirements, r => r is RolesAuthorizationRequirement); + Assert.Contains(canReadWritePlusPolicy.Requirements, r => r is ClaimsAuthorizationRequirement); + } + + [Fact] + public void AddApplicationAuthorization_ShouldApplyOrLogicForRoles() + { + // Arrange + var services = new ServiceCollection(); + + services.AddApplicationAuthorization(_configuration); + + var provider = services.BuildServiceProvider(); + var authorizationOptions = provider.GetRequiredService() as DefaultAuthorizationPolicyProvider; + + // Assert + Assert.NotNull(authorizationOptions); + var policy = authorizationOptions!.GetPolicyAsync("TestPolicy").Result; + Assert.NotNull(policy); + var roleRequirement = policy.Requirements.OfType().SingleOrDefault(); + Assert.NotNull(roleRequirement); + Assert.Contains("Role1", roleRequirement!.AllowedRoles); + Assert.Contains("Role2", roleRequirement.AllowedRoles); + } + + [Fact] + public void AddApplicationAuthorization_ShouldApplyAndLogicForRoles() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddApplicationAuthorization(_configuration); + + var provider = services.BuildServiceProvider(); + var authorizationOptions = provider.GetRequiredService() as DefaultAuthorizationPolicyProvider; + + // Assert + Assert.NotNull(authorizationOptions); + var policy = authorizationOptions!.GetPolicyAsync("TestPolicyAND").Result; + Assert.NotNull(policy); + var roleRequirements = policy.Requirements.OfType().ToList(); + Assert.Equal(2, roleRequirements.Count); + Assert.Contains(roleRequirements, r => r.AllowedRoles.Contains("Role1")); + Assert.Contains(roleRequirements, r => r.AllowedRoles.Contains("Role2")); + } + + [Fact] + public void AddApplicationAuthorization_ShouldAddCustomRequirementViaAction() + { + // Arrange + var services = new ServiceCollection(); + + var requirement = new CustomRequirement(); + var policyCustomizations = new Dictionary> + { + { + "CustomPolicy", builder => builder.Requirements.Add(requirement) + } + }; + + // Act + services.AddApplicationAuthorization(_configuration, policyCustomizations); + + var provider = services.BuildServiceProvider(); + var authorizationOptions = provider.GetRequiredService() as DefaultAuthorizationPolicyProvider; + + // Assert + Assert.NotNull(authorizationOptions); + var policy = authorizationOptions!.GetPolicyAsync("CustomPolicy").Result; + Assert.NotNull(policy); + Assert.Contains(policy.Requirements, r => r == requirement); + } + + [Fact] + public void AddApplicationAuthorization_ShouldAddCustomRequirementToExistingPolicy() + { + // Arrange + var services = new ServiceCollection(); + var customRequirement = new CustomRequirement(); + + var policyCustomizations = new Dictionary> + { + { + "CanReadWritePlus", builder => builder.Requirements.Add(customRequirement) + } + }; + + // Act + services.AddApplicationAuthorization(_configuration, policyCustomizations); + + var provider = services.BuildServiceProvider(); + var authorizationOptions = provider.GetRequiredService() as DefaultAuthorizationPolicyProvider; + + // Assert + Assert.NotNull(authorizationOptions); + + // Retrieve the existing policy + var policy = authorizationOptions!.GetPolicyAsync("CanReadWritePlus").Result; + Assert.NotNull(policy); + + // Verify that the original roles and claims are present + var roleRequirements = policy.Requirements.OfType(); + Assert.NotNull(roleRequirements); + Assert.Contains(roleRequirements, r => r.AllowedRoles.Contains("API.Read")); + Assert.Contains(roleRequirements, r => r.AllowedRoles.Contains("API.Write")); + + var claimsRequirements = policy.Requirements.OfType(); + Assert.NotNull(claimsRequirements); + Assert.Contains(claimsRequirements, cr => cr.ClaimType == "API.PersonalInfo"); + + Assert.Contains(claimsRequirements, cr => cr.ClaimType == "API.PersonalInfo" && cr.AllowedValues.Contains("true")); + + Assert.Contains(policy.Requirements, r => r == customRequirement); + } + } + + public class CustomRequirement : IAuthorizationRequirement { } +} diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/CustomClaimsTransformationTests.cs b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/CustomClaimsTransformationTests.cs new file mode 100644 index 0000000..9e93303 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Security.Tests/AuthorizationTests/CustomClaimsTransformationTests.cs @@ -0,0 +1,50 @@ +using DfE.CoreLibs.Security.Authorization; +using DfE.CoreLibs.Security.Interfaces; +using NSubstitute; +using System.Security.Claims; + +namespace DfE.CoreLibs.Security.Tests.AuthorizationTests +{ + public class CustomClaimsTransformationTests + { + [Fact] + public async Task TransformAsync_ShouldAddClaimsFromAllProviders() + { + // Arrange + var claimProvider1 = Substitute.For(); + claimProvider1.GetClaimsAsync(Arg.Any()).Returns(new List { new Claim("Type1", "Value1") }); + + var claimProvider2 = Substitute.For(); + claimProvider2.GetClaimsAsync(Arg.Any()).Returns(new List { new Claim("Type2", "Value2") }); + + var transformation = new CustomClaimsTransformation(new List { claimProvider1, claimProvider2 }); + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + + // Act + var transformedPrincipal = await transformation.TransformAsync(principal); + + // Assert + Assert.Contains(transformedPrincipal.Claims, c => c.Type == "Type1" && c.Value == "Value1"); + Assert.Contains(transformedPrincipal.Claims, c => c.Type == "Type2" && c.Value == "Value2"); + } + + [Fact] + public async Task TransformAsync_ShouldNotAddDuplicateClaims() + { + // Arrange + var claimProvider = Substitute.For(); + claimProvider.GetClaimsAsync(Arg.Any()) + .Returns(new List { new Claim("Type1", "Value1") }); + + var transformation = new CustomClaimsTransformation(new List { claimProvider }); + var identity = new ClaimsIdentity(new List { new Claim("Type1", "Value1") }); + var principal = new ClaimsPrincipal(identity); + + // Act + var transformedPrincipal = await transformation.TransformAsync(principal); + + // Assert + Assert.Single(transformedPrincipal.Claims, c => c.Type == "Type1" && c.Value == "Value1"); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj b/src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj new file mode 100644 index 0000000..b545dc5 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Security.Tests/DfE.CoreLibs.Security.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json b/src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json new file mode 100644 index 0000000..0d5ebe0 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Security.Tests/appsettings.json @@ -0,0 +1,37 @@ +{ + "Authorization": { + "Policies": [ + { + "Name": "CanRead", + "Operator": "OR", + "Roles": [ "API.Read" ] + }, + { + "Name": "CanReadWrite", + "Operator": "AND", + "Roles": [ "API.Read", "API.Write" ] + }, + { + "Name": "TestPolicy", + "Operator": "OR", + "Roles": [ "Role1", "Role2" ] + }, + { + "Name": "TestPolicyAND", + "Operator": "AND", + "Roles": [ "Role1", "Role2" ] + }, + { + "Name": "CanReadWritePlus", + "Operator": "AND", + "Roles": [ "API.Read", "API.Write" ], + "Claims": [ + { + "Type": "API.PersonalInfo", + "Values": [ "true" ] + } + ] + } + ] + } +}