From 2d3f939e8f42872fdfe8e85229468958ea3f4084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Wed, 25 Feb 2026 13:13:56 -0600 Subject: [PATCH] feat(thalos-dal): add provider-routed identity adapters --- docs/dal/identity-provider-boundaries.md | 4 + .../Contracts/IdentityPermissionRecord.cs | 6 +- .../IdentityPermissionSetLookupRequest.cs | 6 +- .../Contracts/IdentityPolicyLookupRequest.cs | 6 +- .../Contracts/IdentityPolicyRecord.cs | 6 +- .../Contracts/IdentityTokenLookupRequest.cs | 11 +- .../Contracts/IdentityTokenRecord.cs | 6 +- .../Contracts/IdentityUserLookupRequest.cs | 11 +- .../ThalosDalServiceCollectionExtensions.cs | 12 +- .../ProviderPermissionDataProviders.cs | 89 +++++++++++ .../Providers/ProviderUserDataProviders.cs | 143 ++++++++++++++++++ .../Repositories/IdentityRepository.cs | 23 ++- src/Thalos.DAL/Thalos.DAL.csproj | 1 + .../ContractShapeTests.cs | 2 + .../RuntimeWiringTests.cs | 23 +++ 15 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 src/Thalos.DAL/Providers/ProviderPermissionDataProviders.cs create mode 100644 src/Thalos.DAL/Providers/ProviderUserDataProviders.cs diff --git a/docs/dal/identity-provider-boundaries.md b/docs/dal/identity-provider-boundaries.md index f57bc23..d47bf45 100644 --- a/docs/dal/identity-provider-boundaries.md +++ b/docs/dal/identity-provider-boundaries.md @@ -16,3 +16,7 @@ - Provider boundaries remain internal to Thalos DAL. - DAL interfaces expose only transport-neutral contracts and read ports. - Identity abstractions remain Thalos-owned. +- Runtime provider routes currently support: + - `InternalJwt` + - `AzureAd` + - `Google` diff --git a/src/Thalos.DAL/Contracts/IdentityPermissionRecord.cs b/src/Thalos.DAL/Contracts/IdentityPermissionRecord.cs index 8ee10f2..10f8083 100644 --- a/src/Thalos.DAL/Contracts/IdentityPermissionRecord.cs +++ b/src/Thalos.DAL/Contracts/IdentityPermissionRecord.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.DAL.Contracts; /// @@ -6,7 +8,9 @@ namespace Thalos.DAL.Contracts; /// Contract envelope metadata. /// Permission code identifier. /// Role code that grants the permission. +/// Auth provider for the permission grant. public sealed record IdentityPermissionRecord( IdentityContractEnvelope Envelope, string PermissionCode, - string SourceRoleCode); + string SourceRoleCode, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt); diff --git a/src/Thalos.DAL/Contracts/IdentityPermissionSetLookupRequest.cs b/src/Thalos.DAL/Contracts/IdentityPermissionSetLookupRequest.cs index d41d316..78dff81 100644 --- a/src/Thalos.DAL/Contracts/IdentityPermissionSetLookupRequest.cs +++ b/src/Thalos.DAL/Contracts/IdentityPermissionSetLookupRequest.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.DAL.Contracts; /// @@ -6,7 +8,9 @@ namespace Thalos.DAL.Contracts; /// Contract envelope metadata. /// Identity subject identifier. /// Tenant scope identifier. +/// Auth provider for the lookup flow. public sealed record IdentityPermissionSetLookupRequest( IdentityContractEnvelope Envelope, string SubjectId, - string TenantId); + string TenantId, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt); diff --git a/src/Thalos.DAL/Contracts/IdentityPolicyLookupRequest.cs b/src/Thalos.DAL/Contracts/IdentityPolicyLookupRequest.cs index c6a338c..dcac245 100644 --- a/src/Thalos.DAL/Contracts/IdentityPolicyLookupRequest.cs +++ b/src/Thalos.DAL/Contracts/IdentityPolicyLookupRequest.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.DAL.Contracts; /// @@ -7,8 +9,10 @@ namespace Thalos.DAL.Contracts; /// Identity subject identifier. /// Tenant scope identifier. /// Permission code to evaluate. +/// Auth provider for the lookup flow. public sealed record IdentityPolicyLookupRequest( IdentityContractEnvelope Envelope, string SubjectId, string TenantId, - string PermissionCode); + string PermissionCode, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt); diff --git a/src/Thalos.DAL/Contracts/IdentityPolicyRecord.cs b/src/Thalos.DAL/Contracts/IdentityPolicyRecord.cs index 4a47f3e..84f80d9 100644 --- a/src/Thalos.DAL/Contracts/IdentityPolicyRecord.cs +++ b/src/Thalos.DAL/Contracts/IdentityPolicyRecord.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.DAL.Contracts; /// @@ -7,8 +9,10 @@ namespace Thalos.DAL.Contracts; /// Identity subject identifier. /// Permission code evaluated. /// Indicates whether policy context is satisfied. +/// Auth provider used for policy evaluation. public sealed record IdentityPolicyRecord( IdentityContractEnvelope Envelope, string SubjectId, string PermissionCode, - bool ContextSatisfied); + bool ContextSatisfied, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt); diff --git a/src/Thalos.DAL/Contracts/IdentityTokenLookupRequest.cs b/src/Thalos.DAL/Contracts/IdentityTokenLookupRequest.cs index 034118d..0d1d78e 100644 --- a/src/Thalos.DAL/Contracts/IdentityTokenLookupRequest.cs +++ b/src/Thalos.DAL/Contracts/IdentityTokenLookupRequest.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.DAL.Contracts; /// @@ -6,4 +8,11 @@ namespace Thalos.DAL.Contracts; /// Contract envelope metadata. /// Identity subject identifier. /// Tenant scope identifier. -public sealed record IdentityTokenLookupRequest(IdentityContractEnvelope Envelope, string SubjectId, string TenantId); +/// Auth provider for the lookup flow. +/// External provider token when applicable. +public sealed record IdentityTokenLookupRequest( + IdentityContractEnvelope Envelope, + string SubjectId, + string TenantId, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt, + string ExternalToken = ""); diff --git a/src/Thalos.DAL/Contracts/IdentityTokenRecord.cs b/src/Thalos.DAL/Contracts/IdentityTokenRecord.cs index e932ff4..2958a22 100644 --- a/src/Thalos.DAL/Contracts/IdentityTokenRecord.cs +++ b/src/Thalos.DAL/Contracts/IdentityTokenRecord.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.DAL.Contracts; /// @@ -8,9 +10,11 @@ namespace Thalos.DAL.Contracts; /// Tenant scope identifier. /// Issued access token value. /// Token expiration in seconds. +/// Auth provider used for token issuance. public sealed record IdentityTokenRecord( IdentityContractEnvelope Envelope, string SubjectId, string TenantId, string Token, - int ExpiresInSeconds); + int ExpiresInSeconds, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt); diff --git a/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs b/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs index 9c5117f..3e77f55 100644 --- a/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs +++ b/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs @@ -1,3 +1,5 @@ +using BuildingBlock.Identity.Contracts.Conventions; + namespace Thalos.DAL.Contracts; /// @@ -6,4 +8,11 @@ namespace Thalos.DAL.Contracts; /// Contract envelope metadata. /// Identity subject identifier. /// Tenant identifier. -public sealed record IdentityUserLookupRequest(IdentityContractEnvelope Envelope, string SubjectId, string TenantId); +/// Auth provider for the lookup flow. +/// External provider token when applicable. +public sealed record IdentityUserLookupRequest( + IdentityContractEnvelope Envelope, + string SubjectId, + string TenantId, + IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt, + string ExternalToken = ""); diff --git a/src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs b/src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs index 1bc5e73..caf339b 100644 --- a/src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs +++ b/src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs @@ -23,9 +23,17 @@ public static class ThalosDalServiceCollectionExtensions { services.AddBlueprintRuntimeCore(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Thalos.DAL/Providers/ProviderPermissionDataProviders.cs b/src/Thalos.DAL/Providers/ProviderPermissionDataProviders.cs new file mode 100644 index 0000000..ba59994 --- /dev/null +++ b/src/Thalos.DAL/Providers/ProviderPermissionDataProviders.cs @@ -0,0 +1,89 @@ +using BuildingBlock.Identity.Contracts.Conventions; +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Providers; + +/// +/// Internal JWT permission provider implementation. +/// +public sealed class InternalJwtPermissionDataProvider : IPermissionDataProvider +{ + /// + public Task> ReadPermissionsAsync( + IdentityPermissionSetLookupRequest request, + CancellationToken cancellationToken = default) + { + IReadOnlyList records = + [ + new IdentityPermissionRecord(request.Envelope, "identity.token.issue", "identity.admin", IdentityAuthProvider.InternalJwt), + new IdentityPermissionRecord(request.Envelope, "identity.policy.evaluate", "identity.admin", IdentityAuthProvider.InternalJwt) + ]; + + return Task.FromResult(records); + } +} + +/// +/// Azure AD permission provider implementation. +/// +public sealed class AzureAdPermissionDataProvider : IPermissionDataProvider +{ + /// + public Task> ReadPermissionsAsync( + IdentityPermissionSetLookupRequest request, + CancellationToken cancellationToken = default) + { + IReadOnlyList records = + [ + new IdentityPermissionRecord(request.Envelope, "identity.token.issue", "identity.azure.user", IdentityAuthProvider.AzureAd), + new IdentityPermissionRecord(request.Envelope, "identity.policy.evaluate", "identity.azure.user", IdentityAuthProvider.AzureAd), + new IdentityPermissionRecord(request.Envelope, "identity.oauth.exchange", "identity.azure.user", IdentityAuthProvider.AzureAd) + ]; + + return Task.FromResult(records); + } +} + +/// +/// Google permission provider implementation. +/// +public sealed class GooglePermissionDataProvider : IPermissionDataProvider +{ + /// + public Task> ReadPermissionsAsync( + IdentityPermissionSetLookupRequest request, + CancellationToken cancellationToken = default) + { + IReadOnlyList records = + [ + new IdentityPermissionRecord(request.Envelope, "identity.token.issue", "identity.google.user", IdentityAuthProvider.Google), + new IdentityPermissionRecord(request.Envelope, "identity.policy.evaluate", "identity.google.user", IdentityAuthProvider.Google), + new IdentityPermissionRecord(request.Envelope, "identity.oauth.exchange", "identity.google.user", IdentityAuthProvider.Google) + ]; + + return Task.FromResult(records); + } +} + +/// +/// Routes permission lookups to the matching provider implementation. +/// +public sealed class RoutedPermissionDataProvider( + InternalJwtPermissionDataProvider internalJwtProvider, + AzureAdPermissionDataProvider azureProvider, + GooglePermissionDataProvider googleProvider) : IPermissionDataProvider +{ + /// + public Task> ReadPermissionsAsync( + IdentityPermissionSetLookupRequest request, + CancellationToken cancellationToken = default) + { + return request.Provider switch + { + IdentityAuthProvider.InternalJwt => internalJwtProvider.ReadPermissionsAsync(request, cancellationToken), + IdentityAuthProvider.AzureAd => azureProvider.ReadPermissionsAsync(request, cancellationToken), + IdentityAuthProvider.Google => googleProvider.ReadPermissionsAsync(request, cancellationToken), + _ => Task.FromResult>([]) + }; + } +} diff --git a/src/Thalos.DAL/Providers/ProviderUserDataProviders.cs b/src/Thalos.DAL/Providers/ProviderUserDataProviders.cs new file mode 100644 index 0000000..3905fd2 --- /dev/null +++ b/src/Thalos.DAL/Providers/ProviderUserDataProviders.cs @@ -0,0 +1,143 @@ +using BuildingBlock.Identity.Contracts.Conventions; +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Providers; + +/// +/// Internal JWT provider implementation for identity user reads. +/// +public sealed class InternalJwtUserDataProvider : IUserDataProvider +{ + /// + public Task ReadUserAsync( + IdentityUserLookupRequest request, + CancellationToken cancellationToken = default) + { + if (request.SubjectId.StartsWith("missing-", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(null); + } + + var record = new IdentityUserRecord( + request.Envelope, + request.SubjectId, + request.TenantId, + "active", + $"{request.SubjectId}:{request.TenantId}:token", + 1800, + true); + + return Task.FromResult(record); + } +} + +/// +/// Azure AD provider implementation for identity user reads. +/// +public sealed class AzureAdUserDataProvider : IUserDataProvider +{ + /// + public Task ReadUserAsync( + IdentityUserLookupRequest request, + CancellationToken cancellationToken = default) + { + var subjectId = ResolveSubjectId(request, "azure-sub"); + if (string.IsNullOrWhiteSpace(subjectId)) + { + return Task.FromResult(null); + } + + var record = new IdentityUserRecord( + request.Envelope, + subjectId, + request.TenantId, + "active", + $"azure:{subjectId}:{request.TenantId}:token", + 3600, + true); + + return Task.FromResult(record); + } + + private static string ResolveSubjectId(IdentityUserLookupRequest request, string prefix) + { + if (!string.IsNullOrWhiteSpace(request.SubjectId)) + { + return request.SubjectId; + } + + if (string.IsNullOrWhiteSpace(request.ExternalToken)) + { + return string.Empty; + } + + return $"{prefix}-{Math.Abs(request.ExternalToken.GetHashCode(StringComparison.Ordinal))}"; + } +} + +/// +/// Google provider implementation for identity user reads. +/// +public sealed class GoogleUserDataProvider : IUserDataProvider +{ + /// + public Task ReadUserAsync( + IdentityUserLookupRequest request, + CancellationToken cancellationToken = default) + { + var subjectId = ResolveSubjectId(request, "google-sub"); + if (string.IsNullOrWhiteSpace(subjectId)) + { + return Task.FromResult(null); + } + + var record = new IdentityUserRecord( + request.Envelope, + subjectId, + request.TenantId, + "active", + $"google:{subjectId}:{request.TenantId}:token", + 3000, + true); + + return Task.FromResult(record); + } + + private static string ResolveSubjectId(IdentityUserLookupRequest request, string prefix) + { + if (!string.IsNullOrWhiteSpace(request.SubjectId)) + { + return request.SubjectId; + } + + if (string.IsNullOrWhiteSpace(request.ExternalToken)) + { + return string.Empty; + } + + return $"{prefix}-{Math.Abs(request.ExternalToken.GetHashCode(StringComparison.Ordinal))}"; + } +} + +/// +/// Routes user lookups to the matching provider implementation. +/// +public sealed class RoutedUserDataProvider( + InternalJwtUserDataProvider internalJwtProvider, + AzureAdUserDataProvider azureProvider, + GoogleUserDataProvider googleProvider) : IUserDataProvider +{ + /// + public Task ReadUserAsync( + IdentityUserLookupRequest request, + CancellationToken cancellationToken = default) + { + return request.Provider switch + { + IdentityAuthProvider.InternalJwt => internalJwtProvider.ReadUserAsync(request, cancellationToken), + IdentityAuthProvider.AzureAd => azureProvider.ReadUserAsync(request, cancellationToken), + IdentityAuthProvider.Google => googleProvider.ReadUserAsync(request, cancellationToken), + _ => Task.FromResult(null) + }; + } +} diff --git a/src/Thalos.DAL/Repositories/IdentityRepository.cs b/src/Thalos.DAL/Repositories/IdentityRepository.cs index bbe768c..75ea161 100644 --- a/src/Thalos.DAL/Repositories/IdentityRepository.cs +++ b/src/Thalos.DAL/Repositories/IdentityRepository.cs @@ -15,7 +15,12 @@ public sealed class IdentityRepository( IdentityTokenLookupRequest request, CancellationToken cancellationToken = default) { - var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId, request.TenantId); + var userRequest = new IdentityUserLookupRequest( + request.Envelope, + request.SubjectId, + request.TenantId, + request.Provider, + request.ExternalToken); var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken); if (userRecord is null) { @@ -24,10 +29,11 @@ public sealed class IdentityRepository( return new IdentityTokenRecord( request.Envelope, - request.SubjectId, + userRecord.SubjectId, request.TenantId, userRecord.Token, - userRecord.ExpiresInSeconds); + userRecord.ExpiresInSeconds, + request.Provider); } /// @@ -35,7 +41,11 @@ public sealed class IdentityRepository( IdentityPolicyLookupRequest request, CancellationToken cancellationToken = default) { - var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId, request.TenantId); + var userRequest = new IdentityUserLookupRequest( + request.Envelope, + request.SubjectId, + request.TenantId, + request.Provider); var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken); if (userRecord is null) { @@ -44,9 +54,10 @@ public sealed class IdentityRepository( return new IdentityPolicyRecord( request.Envelope, - request.SubjectId, + userRecord.SubjectId, request.PermissionCode, - userRecord.ContextSatisfied); + userRecord.ContextSatisfied, + request.Provider); } /// diff --git a/src/Thalos.DAL/Thalos.DAL.csproj b/src/Thalos.DAL/Thalos.DAL.csproj index bf5537a..4eb2668 100644 --- a/src/Thalos.DAL/Thalos.DAL.csproj +++ b/src/Thalos.DAL/Thalos.DAL.csproj @@ -6,6 +6,7 @@ + diff --git a/tests/Thalos.DAL.UnitTests/ContractShapeTests.cs b/tests/Thalos.DAL.UnitTests/ContractShapeTests.cs index 58949ba..358e158 100644 --- a/tests/Thalos.DAL.UnitTests/ContractShapeTests.cs +++ b/tests/Thalos.DAL.UnitTests/ContractShapeTests.cs @@ -16,6 +16,7 @@ public class ContractShapeTests Assert.Equal("user-1", request.SubjectId); Assert.Equal("tenant-1", request.TenantId); Assert.Equal("identity.token.issue", request.PermissionCode); + Assert.Equal(BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.InternalJwt, request.Provider); } [Fact] @@ -30,6 +31,7 @@ public class ContractShapeTests Assert.Equal("tenant-1", record.TenantId); Assert.Equal("token-xyz", record.Token); Assert.Equal(1800, record.ExpiresInSeconds); + Assert.Equal(BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.InternalJwt, record.Provider); } [Fact] diff --git a/tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs b/tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs index 6c902fc..a07cb27 100644 --- a/tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs +++ b/tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs @@ -28,6 +28,29 @@ public class RuntimeWiringTests Assert.Equal("user-1", response.SubjectId); Assert.Equal("tenant-1", response.TenantId); Assert.Equal(1800, response.ExpiresInSeconds); + Assert.Equal(BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.InternalJwt, response.Provider); + } + + [Fact] + public async Task AddThalosDalRuntime_WhenExternalProviderUsed_ResolvesProviderSpecificToken() + { + var services = new ServiceCollection(); + services.AddThalosDalRuntime(); + + using var provider = services.BuildServiceProvider(); + var repository = provider.GetRequiredService(); + var request = new IdentityTokenLookupRequest( + new IdentityContractEnvelope("1.0.0", "corr-ext"), + string.Empty, + "tenant-2", + BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.AzureAd, + "external-azure-token"); + + var response = await repository.ReadIdentityTokenAsync(request); + + Assert.NotNull(response); + Assert.Equal(BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.AzureAd, response.Provider); + Assert.StartsWith("azure:", response.Token); } [Fact]