From fcea61b599c0d0d652262c623960ebe6a0adfe5b Mon Sep 17 00:00:00 2001 From: Ponchanon Datta Rone Date: Wed, 12 Aug 2026 14:53:19 -0500 Subject: [PATCH] feat(authorization): add policy composition --- .../Authorization/AuthorizationPolicySet.cs | 50 +++++++++++ .../Authorization/IAuthorizationPolicySet.cs | 22 +++++ .../AuthorizationPolicySetTests.cs | 88 +++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/BuildingBlocks/Security/Authorization/AuthorizationPolicySet.cs create mode 100644 src/BuildingBlocks/Security/Authorization/IAuthorizationPolicySet.cs create mode 100644 tests/UnitTests/Authorization/AuthorizationPolicySetTests.cs diff --git a/src/BuildingBlocks/Security/Authorization/AuthorizationPolicySet.cs b/src/BuildingBlocks/Security/Authorization/AuthorizationPolicySet.cs new file mode 100644 index 0000000..6cc5dd3 --- /dev/null +++ b/src/BuildingBlocks/Security/Authorization/AuthorizationPolicySet.cs @@ -0,0 +1,50 @@ +namespace OpenHealthOS.Security.Authorization; + +using OpenHealthOS.Contracts.Authorization; + +/// +/// Evaluates a collection of authorization policies using AND semantics. +/// +public sealed class AuthorizationPolicySet : IAuthorizationPolicySet +{ + private readonly IReadOnlyCollection _policies; + + /// + /// Initializes a new instance of the + /// class. + /// + /// + /// The policies that must all be satisfied. + /// + public AuthorizationPolicySet( + IEnumerable policies) + { + ArgumentNullException.ThrowIfNull(policies); + + _policies = policies.ToArray(); + + if (_policies.Any(policy => policy is null)) + { + throw new ArgumentException( + "Policy collection cannot contain null policies.", + nameof(policies)); + } + } + + /// + /// Determines whether all policies are satisfied. + /// + /// + /// The authorization context to evaluate. + /// + /// + /// when every policy is satisfied; + /// otherwise . + /// + public bool IsSatisfied(AuthorizationContextDto context) + { + ArgumentNullException.ThrowIfNull(context); + + return _policies.All(policy => policy.IsSatisfied(context)); + } +} diff --git a/src/BuildingBlocks/Security/Authorization/IAuthorizationPolicySet.cs b/src/BuildingBlocks/Security/Authorization/IAuthorizationPolicySet.cs new file mode 100644 index 0000000..bdcb65e --- /dev/null +++ b/src/BuildingBlocks/Security/Authorization/IAuthorizationPolicySet.cs @@ -0,0 +1,22 @@ +namespace OpenHealthOS.Security.Authorization; + +using OpenHealthOS.Contracts.Authorization; + +/// +/// Represents a composition of authorization policies. +/// +public interface IAuthorizationPolicySet +{ + /// + /// Determines whether the authorization context satisfies all policies + /// in the set. + /// + /// + /// The authorization context to evaluate. + /// + /// + /// when all policies are satisfied; + /// otherwise . + /// + bool IsSatisfied(AuthorizationContextDto context); +} diff --git a/tests/UnitTests/Authorization/AuthorizationPolicySetTests.cs b/tests/UnitTests/Authorization/AuthorizationPolicySetTests.cs new file mode 100644 index 0000000..86f7460 --- /dev/null +++ b/tests/UnitTests/Authorization/AuthorizationPolicySetTests.cs @@ -0,0 +1,88 @@ +namespace OpenHealthOS.UnitTests.Authorization; + +using OpenHealthOS.Contracts.Authorization; +using OpenHealthOS.Contracts.Identity; +using OpenHealthOS.Security.Authorization; +using Xunit; + +public sealed class AuthorizationPolicySetTests +{ + [Fact] + public void IsSatisfied_ShouldAllow_WhenAllPoliciesAreSatisfied() + { + var context = CreateContext("patient.read"); + + var policySet = new AuthorizationPolicySet( + [ + new PermissionPolicy(new Permission("patient.read")), + new PermissionPolicy(new Permission("PATIENT.READ")), + ]); + + var result = policySet.IsSatisfied(context); + + Assert.True(result); + } + + [Fact] + public void IsSatisfied_ShouldDeny_WhenAnyPolicyIsNotSatisfied() + { + var context = CreateContext("patient.read"); + + var policySet = new AuthorizationPolicySet( + [ + new PermissionPolicy(new Permission("patient.read")), + new PermissionPolicy(new Permission("patient.write")), + ]); + + var result = policySet.IsSatisfied(context); + + Assert.False(result); + } + + [Fact] + public void IsSatisfied_ShouldAllow_WhenPolicySetIsEmpty() + { + var context = CreateContext("patient.read"); + + var policySet = new AuthorizationPolicySet( + Array.Empty()); + + var result = policySet.IsSatisfied(context); + + Assert.True(result); + } + + [Fact] + public void IsSatisfied_ShouldThrow_WhenContextIsNull() + { + var policySet = new AuthorizationPolicySet( + [ + new PermissionPolicy(new Permission("patient.read")), + ]); + + Assert.Throws( + () => policySet.IsSatisfied(null!)); + } + + [Fact] + public void Constructor_ShouldThrow_WhenPoliciesIsNull() + { + Assert.Throws( + () => new AuthorizationPolicySet(null!)); + } + + private static AuthorizationContextDto CreateContext( + string permission) + { + return new AuthorizationContextDto + { + Identity = new IdentityContextDto + { + SubjectId = "test-user", + PrincipalType = PrincipalType.User, + }, + Permission = new Permission(permission), + ResourceType = "Patient", + }; + } +}