diff --git a/docs/migration/policy-behavior-invariants.md b/docs/migration/policy-behavior-invariants.md index 8c84ef2..1e50ace 100644 --- a/docs/migration/policy-behavior-invariants.md +++ b/docs/migration/policy-behavior-invariants.md @@ -3,6 +3,9 @@ ## Invariants - Equivalent policy inputs produce equivalent policy decisions. - Token decision fallback behavior remains stable until explicitly revised. +- Provider semantics are explicit: + - `InternalJwt`: standard identity permission evaluation. + - `AzureAd` and `Google`: policy permission must remain within `identity.*` scope. - Service transport contracts remain stable during domain extraction. ## Validation Approach diff --git a/src/Thalos.Domain/Contracts/IdentityPolicyContextData.cs b/src/Thalos.Domain/Contracts/IdentityPolicyContextData.cs index 075c63e..b40cf45 100644 --- a/src/Thalos.Domain/Contracts/IdentityPolicyContextData.cs +++ b/src/Thalos.Domain/Contracts/IdentityPolicyContextData.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.Domain.Contracts; /// @@ -5,10 +7,12 @@ namespace Thalos.Domain.Contracts; /// /// Identity subject identifier. /// Permission code being evaluated. +/// Auth provider used in the request flow. /// Whether contextual constraints are satisfied. /// Permissions granted to subject in tenant scope. public sealed record IdentityPolicyContextData( string SubjectId, string PermissionCode, + IdentityAuthProvider Provider, bool ContextSatisfied, IReadOnlyCollection GrantedPermissions); diff --git a/src/Thalos.Domain/Contracts/IdentityTokenData.cs b/src/Thalos.Domain/Contracts/IdentityTokenData.cs index 5aaa9b8..f43d312 100644 --- a/src/Thalos.Domain/Contracts/IdentityTokenData.cs +++ b/src/Thalos.Domain/Contracts/IdentityTokenData.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.Domain.Contracts; /// @@ -5,4 +7,8 @@ namespace Thalos.Domain.Contracts; /// /// Token value, if found. /// Token lifetime, if found. -public sealed record IdentityTokenData(string? Token, int? ExpiresInSeconds); +/// Auth provider used in the issuance flow. +public sealed record IdentityTokenData( + string? Token, + int? ExpiresInSeconds, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt); diff --git a/src/Thalos.Domain/Decisions/IdentityPolicyDecisionService.cs b/src/Thalos.Domain/Decisions/IdentityPolicyDecisionService.cs index 406414f..c382ff6 100644 --- a/src/Thalos.Domain/Decisions/IdentityPolicyDecisionService.cs +++ b/src/Thalos.Domain/Decisions/IdentityPolicyDecisionService.cs @@ -1,5 +1,6 @@ using BuildingBlock.Identity.Contracts.Requests; using BuildingBlock.Identity.Contracts.Responses; +using BuildingBlock.Identity.Contracts.Conventions; using Thalos.Domain.Contracts; namespace Thalos.Domain.Decisions; @@ -12,7 +13,11 @@ public sealed class IdentityPolicyDecisionService : IIdentityPolicyDecisionServi /// public IdentityPolicyContextRequest BuildPolicyContextRequest(EvaluateIdentityPolicyRequest request) { - return new IdentityPolicyContextRequest(request.SubjectId, request.TenantId, request.PermissionCode); + return new IdentityPolicyContextRequest( + request.SubjectId, + request.TenantId, + request.PermissionCode, + request.Provider); } /// @@ -22,10 +27,33 @@ public sealed class IdentityPolicyDecisionService : IIdentityPolicyDecisionServi { var permissionMatched = policyContextData.GrantedPermissions.Any(permission => string.Equals(permission, request.PermissionCode, StringComparison.OrdinalIgnoreCase)); + var providerSatisfied = IsProviderContextSatisfied(request.Provider, policyContextData); return new EvaluateIdentityPolicyResponse( request.SubjectId, request.PermissionCode, - policyContextData.ContextSatisfied && permissionMatched); + providerSatisfied && permissionMatched); + } + + private static bool IsProviderContextSatisfied( + IdentityAuthProvider provider, + IdentityPolicyContextData policyContextData) + { + if (!policyContextData.ContextSatisfied) + { + return false; + } + + return provider switch + { + IdentityAuthProvider.InternalJwt => true, + IdentityAuthProvider.AzureAd => policyContextData.PermissionCode.StartsWith( + "identity.", + StringComparison.OrdinalIgnoreCase), + IdentityAuthProvider.Google => policyContextData.PermissionCode.StartsWith( + "identity.", + StringComparison.OrdinalIgnoreCase), + _ => false + }; } } diff --git a/tests/Thalos.Domain.UnitTests/IdentityPolicyDecisionServiceTests.cs b/tests/Thalos.Domain.UnitTests/IdentityPolicyDecisionServiceTests.cs index 1a692ce..c0bb086 100644 --- a/tests/Thalos.Domain.UnitTests/IdentityPolicyDecisionServiceTests.cs +++ b/tests/Thalos.Domain.UnitTests/IdentityPolicyDecisionServiceTests.cs @@ -1,4 +1,5 @@ using BuildingBlock.Identity.Contracts.Requests; +using BuildingBlock.Identity.Contracts.Conventions; using Thalos.Domain.Contracts; using Thalos.Domain.Decisions; @@ -10,10 +11,15 @@ public class IdentityPolicyDecisionServiceTests public void Evaluate_WhenPermissionMatchedAndContextSatisfied_ReturnsAllowed() { var service = new IdentityPolicyDecisionService(); - var request = new EvaluateIdentityPolicyRequest("user-1", "tenant-1", "identity.token.issue"); + var request = new EvaluateIdentityPolicyRequest( + "user-1", + "tenant-1", + "identity.token.issue", + IdentityAuthProvider.InternalJwt); var context = new IdentityPolicyContextData( request.SubjectId, request.PermissionCode, + request.Provider, true, ["identity.token.issue", "identity.policy.evaluate"]); @@ -26,8 +32,38 @@ public class IdentityPolicyDecisionServiceTests public void Evaluate_WhenPermissionMissing_ReturnsDenied() { var service = new IdentityPolicyDecisionService(); - var request = new EvaluateIdentityPolicyRequest("user-1", "tenant-1", "identity.token.issue"); - var context = new IdentityPolicyContextData(request.SubjectId, request.PermissionCode, true, ["identity.read"]); + var request = new EvaluateIdentityPolicyRequest( + "user-1", + "tenant-1", + "identity.token.issue", + IdentityAuthProvider.InternalJwt); + var context = new IdentityPolicyContextData( + request.SubjectId, + request.PermissionCode, + request.Provider, + true, + ["identity.read"]); + + var response = service.Evaluate(request, context); + + Assert.False(response.IsAllowed); + } + + [Fact] + public void Evaluate_WhenProviderIsExternalAndPermissionPrefixInvalid_ReturnsDenied() + { + var service = new IdentityPolicyDecisionService(); + var request = new EvaluateIdentityPolicyRequest( + "user-2", + "tenant-2", + "catalog.read", + IdentityAuthProvider.AzureAd); + var context = new IdentityPolicyContextData( + request.SubjectId, + request.PermissionCode, + request.Provider, + true, + ["catalog.read"]); var response = service.Evaluate(request, context);