diff --git a/.gitignore b/.gitignore index 31c7257..89d521f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ .tasks/ .agile/ +bin/ +obj/ +TestResults/ +.vs/ +*.user +*.suo diff --git a/Thalos.Domain.slnx b/Thalos.Domain.slnx new file mode 100644 index 0000000..dc4bd19 --- /dev/null +++ b/Thalos.Domain.slnx @@ -0,0 +1,8 @@ + + + + + + + + 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 new file mode 100644 index 0000000..b40cf45 --- /dev/null +++ b/src/Thalos.Domain/Contracts/IdentityPolicyContextData.cs @@ -0,0 +1,18 @@ +using BuildingBlock.Identity.Contracts.Conventions; + +namespace Thalos.Domain.Contracts; + +/// +/// Domain input describing policy context and granted permissions for evaluation. +/// +/// 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 new file mode 100644 index 0000000..f43d312 --- /dev/null +++ b/src/Thalos.Domain/Contracts/IdentityTokenData.cs @@ -0,0 +1,14 @@ +using BuildingBlock.Identity.Contracts.Conventions; + +namespace Thalos.Domain.Contracts; + +/// +/// Domain input describing issued token projection from technical persistence. +/// +/// Token value, if found. +/// Token lifetime, if found. +/// 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/Conventions/ThalosDomainPackageContract.cs b/src/Thalos.Domain/Conventions/ThalosDomainPackageContract.cs new file mode 100644 index 0000000..961458b --- /dev/null +++ b/src/Thalos.Domain/Conventions/ThalosDomainPackageContract.cs @@ -0,0 +1,8 @@ +namespace Thalos.Domain.Conventions; + +/// +/// Marker type for thalos-domain package discovery. +/// +public sealed class ThalosDomainPackageContract +{ +} diff --git a/src/Thalos.Domain/Decisions/IIdentityPolicyDecisionService.cs b/src/Thalos.Domain/Decisions/IIdentityPolicyDecisionService.cs new file mode 100644 index 0000000..44c17ac --- /dev/null +++ b/src/Thalos.Domain/Decisions/IIdentityPolicyDecisionService.cs @@ -0,0 +1,23 @@ +using BuildingBlock.Identity.Contracts.Requests; +using BuildingBlock.Identity.Contracts.Responses; +using Thalos.Domain.Contracts; + +namespace Thalos.Domain.Decisions; + +/// +/// Defines domain decision boundary for identity policy workflows. +/// +public interface IIdentityPolicyDecisionService +{ + /// + /// Builds policy context request from policy evaluation request. + /// + IdentityPolicyContextRequest BuildPolicyContextRequest(EvaluateIdentityPolicyRequest request); + + /// + /// Evaluates policy response from contextual data. + /// + EvaluateIdentityPolicyResponse Evaluate( + EvaluateIdentityPolicyRequest request, + IdentityPolicyContextData policyContextData); +} diff --git a/src/Thalos.Domain/Decisions/IIdentityTokenDecisionService.cs b/src/Thalos.Domain/Decisions/IIdentityTokenDecisionService.cs new file mode 100644 index 0000000..0f161ea --- /dev/null +++ b/src/Thalos.Domain/Decisions/IIdentityTokenDecisionService.cs @@ -0,0 +1,15 @@ +using BuildingBlock.Identity.Contracts.Responses; +using Thalos.Domain.Contracts; + +namespace Thalos.Domain.Decisions; + +/// +/// Defines domain decision boundary for identity token issuance semantics. +/// +public interface IIdentityTokenDecisionService +{ + /// + /// Builds token response from technical token data using domain fallback policy. + /// + IssueIdentityTokenResponse BuildIssuedTokenResponse(IdentityTokenData tokenData); +} diff --git a/src/Thalos.Domain/Decisions/IdentityPolicyDecisionService.cs b/src/Thalos.Domain/Decisions/IdentityPolicyDecisionService.cs new file mode 100644 index 0000000..c382ff6 --- /dev/null +++ b/src/Thalos.Domain/Decisions/IdentityPolicyDecisionService.cs @@ -0,0 +1,59 @@ +using BuildingBlock.Identity.Contracts.Requests; +using BuildingBlock.Identity.Contracts.Responses; +using BuildingBlock.Identity.Contracts.Conventions; +using Thalos.Domain.Contracts; + +namespace Thalos.Domain.Decisions; + +/// +/// Default domain implementation for identity policy decision workflows. +/// +public sealed class IdentityPolicyDecisionService : IIdentityPolicyDecisionService +{ + /// + public IdentityPolicyContextRequest BuildPolicyContextRequest(EvaluateIdentityPolicyRequest request) + { + return new IdentityPolicyContextRequest( + request.SubjectId, + request.TenantId, + request.PermissionCode, + request.Provider); + } + + /// + public EvaluateIdentityPolicyResponse Evaluate( + EvaluateIdentityPolicyRequest request, + IdentityPolicyContextData policyContextData) + { + 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, + 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/src/Thalos.Domain/Decisions/IdentityTokenDecisionService.cs b/src/Thalos.Domain/Decisions/IdentityTokenDecisionService.cs new file mode 100644 index 0000000..22fbb9e --- /dev/null +++ b/src/Thalos.Domain/Decisions/IdentityTokenDecisionService.cs @@ -0,0 +1,21 @@ +using BuildingBlock.Identity.Contracts.Responses; +using Thalos.Domain.Contracts; + +namespace Thalos.Domain.Decisions; + +/// +/// Default domain implementation for token issuance fallback semantics. +/// +public sealed class IdentityTokenDecisionService : IIdentityTokenDecisionService +{ + /// + public IssueIdentityTokenResponse BuildIssuedTokenResponse(IdentityTokenData tokenData) + { + if (string.IsNullOrWhiteSpace(tokenData.Token) || !tokenData.ExpiresInSeconds.HasValue) + { + return new IssueIdentityTokenResponse(string.Empty, 0); + } + + return new IssueIdentityTokenResponse(tokenData.Token, tokenData.ExpiresInSeconds.Value); + } +} diff --git a/src/Thalos.Domain/Thalos.Domain.csproj b/src/Thalos.Domain/Thalos.Domain.csproj new file mode 100644 index 0000000..20d5591 --- /dev/null +++ b/src/Thalos.Domain/Thalos.Domain.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + + + + + diff --git a/tests/Thalos.Domain.UnitTests/IdentityPolicyDecisionServiceTests.cs b/tests/Thalos.Domain.UnitTests/IdentityPolicyDecisionServiceTests.cs new file mode 100644 index 0000000..c0bb086 --- /dev/null +++ b/tests/Thalos.Domain.UnitTests/IdentityPolicyDecisionServiceTests.cs @@ -0,0 +1,72 @@ +using BuildingBlock.Identity.Contracts.Requests; +using BuildingBlock.Identity.Contracts.Conventions; +using Thalos.Domain.Contracts; +using Thalos.Domain.Decisions; + +namespace Thalos.Domain.UnitTests; + +public class IdentityPolicyDecisionServiceTests +{ + [Fact] + public void Evaluate_WhenPermissionMatchedAndContextSatisfied_ReturnsAllowed() + { + var service = new IdentityPolicyDecisionService(); + 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"]); + + var response = service.Evaluate(request, context); + + Assert.True(response.IsAllowed); + } + + [Fact] + public void Evaluate_WhenPermissionMissing_ReturnsDenied() + { + var service = new IdentityPolicyDecisionService(); + 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); + + Assert.False(response.IsAllowed); + } +} diff --git a/tests/Thalos.Domain.UnitTests/IdentityTokenDecisionServiceTests.cs b/tests/Thalos.Domain.UnitTests/IdentityTokenDecisionServiceTests.cs new file mode 100644 index 0000000..9303fe9 --- /dev/null +++ b/tests/Thalos.Domain.UnitTests/IdentityTokenDecisionServiceTests.cs @@ -0,0 +1,29 @@ +using Thalos.Domain.Contracts; +using Thalos.Domain.Decisions; + +namespace Thalos.Domain.UnitTests; + +public class IdentityTokenDecisionServiceTests +{ + [Fact] + public void BuildIssuedTokenResponse_WhenTokenMissing_ReturnsFallbackShape() + { + var service = new IdentityTokenDecisionService(); + + var response = service.BuildIssuedTokenResponse(new IdentityTokenData(null, null)); + + Assert.Equal(string.Empty, response.Token); + Assert.Equal(0, response.ExpiresInSeconds); + } + + [Fact] + public void BuildIssuedTokenResponse_WhenTokenExists_ReturnsIssuedToken() + { + var service = new IdentityTokenDecisionService(); + + var response = service.BuildIssuedTokenResponse(new IdentityTokenData("token-123", 1800)); + + Assert.Equal("token-123", response.Token); + Assert.Equal(1800, response.ExpiresInSeconds); + } +} diff --git a/tests/Thalos.Domain.UnitTests/Thalos.Domain.UnitTests.csproj b/tests/Thalos.Domain.UnitTests/Thalos.Domain.UnitTests.csproj new file mode 100644 index 0000000..01a6c86 --- /dev/null +++ b/tests/Thalos.Domain.UnitTests/Thalos.Domain.UnitTests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + +