feat(thalos-domain): add provider-aware policy invariants

This commit is contained in:
José René White Enciso 2026-02-25 13:13:56 -06:00
parent a0d22d3ea2
commit 0430af9396
5 changed files with 83 additions and 6 deletions

View File

@ -3,6 +3,9 @@
## Invariants ## Invariants
- Equivalent policy inputs produce equivalent policy decisions. - Equivalent policy inputs produce equivalent policy decisions.
- Token decision fallback behavior remains stable until explicitly revised. - 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. - Service transport contracts remain stable during domain extraction.
## Validation Approach ## Validation Approach

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.Domain.Contracts; namespace Thalos.Domain.Contracts;
/// <summary> /// <summary>
@ -5,10 +7,12 @@ namespace Thalos.Domain.Contracts;
/// </summary> /// </summary>
/// <param name="SubjectId">Identity subject identifier.</param> /// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="PermissionCode">Permission code being evaluated.</param> /// <param name="PermissionCode">Permission code being evaluated.</param>
/// <param name="Provider">Auth provider used in the request flow.</param>
/// <param name="ContextSatisfied">Whether contextual constraints are satisfied.</param> /// <param name="ContextSatisfied">Whether contextual constraints are satisfied.</param>
/// <param name="GrantedPermissions">Permissions granted to subject in tenant scope.</param> /// <param name="GrantedPermissions">Permissions granted to subject in tenant scope.</param>
public sealed record IdentityPolicyContextData( public sealed record IdentityPolicyContextData(
string SubjectId, string SubjectId,
string PermissionCode, string PermissionCode,
IdentityAuthProvider Provider,
bool ContextSatisfied, bool ContextSatisfied,
IReadOnlyCollection<string> GrantedPermissions); IReadOnlyCollection<string> GrantedPermissions);

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.Domain.Contracts; namespace Thalos.Domain.Contracts;
/// <summary> /// <summary>
@ -5,4 +7,8 @@ namespace Thalos.Domain.Contracts;
/// </summary> /// </summary>
/// <param name="Token">Token value, if found.</param> /// <param name="Token">Token value, if found.</param>
/// <param name="ExpiresInSeconds">Token lifetime, if found.</param> /// <param name="ExpiresInSeconds">Token lifetime, if found.</param>
public sealed record IdentityTokenData(string? Token, int? ExpiresInSeconds); /// <param name="Provider">Auth provider used in the issuance flow.</param>
public sealed record IdentityTokenData(
string? Token,
int? ExpiresInSeconds,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt);

View File

@ -1,5 +1,6 @@
using BuildingBlock.Identity.Contracts.Requests; using BuildingBlock.Identity.Contracts.Requests;
using BuildingBlock.Identity.Contracts.Responses; using BuildingBlock.Identity.Contracts.Responses;
using BuildingBlock.Identity.Contracts.Conventions;
using Thalos.Domain.Contracts; using Thalos.Domain.Contracts;
namespace Thalos.Domain.Decisions; namespace Thalos.Domain.Decisions;
@ -12,7 +13,11 @@ public sealed class IdentityPolicyDecisionService : IIdentityPolicyDecisionServi
/// <inheritdoc /> /// <inheritdoc />
public IdentityPolicyContextRequest BuildPolicyContextRequest(EvaluateIdentityPolicyRequest request) 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);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -22,10 +27,33 @@ public sealed class IdentityPolicyDecisionService : IIdentityPolicyDecisionServi
{ {
var permissionMatched = policyContextData.GrantedPermissions.Any(permission => var permissionMatched = policyContextData.GrantedPermissions.Any(permission =>
string.Equals(permission, request.PermissionCode, StringComparison.OrdinalIgnoreCase)); string.Equals(permission, request.PermissionCode, StringComparison.OrdinalIgnoreCase));
var providerSatisfied = IsProviderContextSatisfied(request.Provider, policyContextData);
return new EvaluateIdentityPolicyResponse( return new EvaluateIdentityPolicyResponse(
request.SubjectId, request.SubjectId,
request.PermissionCode, 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
};
} }
} }

View File

@ -1,4 +1,5 @@
using BuildingBlock.Identity.Contracts.Requests; using BuildingBlock.Identity.Contracts.Requests;
using BuildingBlock.Identity.Contracts.Conventions;
using Thalos.Domain.Contracts; using Thalos.Domain.Contracts;
using Thalos.Domain.Decisions; using Thalos.Domain.Decisions;
@ -10,10 +11,15 @@ public class IdentityPolicyDecisionServiceTests
public void Evaluate_WhenPermissionMatchedAndContextSatisfied_ReturnsAllowed() public void Evaluate_WhenPermissionMatchedAndContextSatisfied_ReturnsAllowed()
{ {
var service = new IdentityPolicyDecisionService(); 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( var context = new IdentityPolicyContextData(
request.SubjectId, request.SubjectId,
request.PermissionCode, request.PermissionCode,
request.Provider,
true, true,
["identity.token.issue", "identity.policy.evaluate"]); ["identity.token.issue", "identity.policy.evaluate"]);
@ -26,8 +32,38 @@ public class IdentityPolicyDecisionServiceTests
public void Evaluate_WhenPermissionMissing_ReturnsDenied() public void Evaluate_WhenPermissionMissing_ReturnsDenied()
{ {
var service = new IdentityPolicyDecisionService(); var service = new IdentityPolicyDecisionService();
var request = new EvaluateIdentityPolicyRequest("user-1", "tenant-1", "identity.token.issue"); var request = new EvaluateIdentityPolicyRequest(
var context = new IdentityPolicyContextData(request.SubjectId, request.PermissionCode, true, ["identity.read"]); "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); var response = service.Evaluate(request, context);