From f7f0e787b675e21f5a4ab468917e2b1281a5ca61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Sun, 22 Feb 2026 17:14:05 -0600 Subject: [PATCH 1/3] feat(thalos-dal): add runtime provider and repository wiring --- .../IdentityDalGrpcContractAdapter.cs | 47 ++++++++++++++ .../ThalosDalServiceCollectionExtensions.cs | 38 +++++++++++ .../Health/DalDependencyHealthCheck.cs | 27 ++++++++ .../InMemory/InMemoryModuleDataProvider.cs | 22 +++++++ .../InMemoryPermissionDataProvider.cs | 23 +++++++ .../InMemory/InMemoryRoleDataProvider.cs | 22 +++++++ .../InMemory/InMemoryTenantDataProvider.cs | 23 +++++++ .../InMemory/InMemoryUserDataProvider.cs | 28 ++++++++ .../Repositories/IdentityRepository.cs | 52 +++++++++++++++ src/Thalos.DAL/Thalos.DAL.csproj | 1 + .../RuntimeWiringTests.cs | 65 +++++++++++++++++++ .../Thalos.DAL.UnitTests.csproj | 1 + 12 files changed, 349 insertions(+) create mode 100644 src/Thalos.DAL/Adapters/IdentityDalGrpcContractAdapter.cs create mode 100644 src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs create mode 100644 src/Thalos.DAL/Health/DalDependencyHealthCheck.cs create mode 100644 src/Thalos.DAL/Providers/InMemory/InMemoryModuleDataProvider.cs create mode 100644 src/Thalos.DAL/Providers/InMemory/InMemoryPermissionDataProvider.cs create mode 100644 src/Thalos.DAL/Providers/InMemory/InMemoryRoleDataProvider.cs create mode 100644 src/Thalos.DAL/Providers/InMemory/InMemoryTenantDataProvider.cs create mode 100644 src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs create mode 100644 src/Thalos.DAL/Repositories/IdentityRepository.cs create mode 100644 tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs diff --git a/src/Thalos.DAL/Adapters/IdentityDalGrpcContractAdapter.cs b/src/Thalos.DAL/Adapters/IdentityDalGrpcContractAdapter.cs new file mode 100644 index 0000000..f07d917 --- /dev/null +++ b/src/Thalos.DAL/Adapters/IdentityDalGrpcContractAdapter.cs @@ -0,0 +1,47 @@ +using Core.Blueprint.Common.Runtime; +using Thalos.DAL.Contracts; +using Thalos.DAL.Grpc; + +namespace Thalos.DAL.Adapters; + +/// +/// Default adapter implementation for DAL gRPC contract translation. +/// +public sealed class IdentityDalGrpcContractAdapter(IBlueprintSystemClock clock) : IIdentityDalGrpcContractAdapter +{ + /// + public IdentityPolicyDalGrpcContract ToGrpcPolicyRequest(IdentityPolicyLookupRequest request) + { + return new IdentityPolicyDalGrpcContract(request.SubjectId, request.TenantId, request.PermissionCode); + } + + /// + public IdentityPolicyLookupRequest FromGrpcPolicyRequest(IdentityPolicyDalGrpcContract contract) + { + return new IdentityPolicyLookupRequest( + CreateEnvelope(), + contract.SubjectId, + contract.TenantId, + contract.PermissionCode); + } + + /// + public IdentityTokenDalGrpcContract ToGrpcTokenRequest(IdentityTokenLookupRequest request) + { + return new IdentityTokenDalGrpcContract(request.SubjectId, request.TenantId); + } + + /// + public IdentityTokenLookupRequest FromGrpcTokenRequest(IdentityTokenDalGrpcContract contract) + { + return new IdentityTokenLookupRequest( + CreateEnvelope(), + contract.SubjectId, + contract.TenantId); + } + + private IdentityContractEnvelope CreateEnvelope() + { + return new IdentityContractEnvelope("1.0.0", $"corr-{clock.UtcNow:yyyyMMddHHmmssfff}"); + } +} diff --git a/src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs b/src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs new file mode 100644 index 0000000..1bc5e73 --- /dev/null +++ b/src/Thalos.DAL/DependencyInjection/ThalosDalServiceCollectionExtensions.cs @@ -0,0 +1,38 @@ +using Core.Blueprint.Common.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Thalos.DAL.Adapters; +using Thalos.DAL.Health; +using Thalos.DAL.Providers; +using Thalos.DAL.Providers.InMemory; +using Thalos.DAL.Repositories; + +namespace Thalos.DAL.DependencyInjection; + +/// +/// Registers thalos dal runtime provider, repository, and adapter implementations. +/// +public static class ThalosDalServiceCollectionExtensions +{ + /// + /// Adds thalos dal runtime implementations aligned with blueprint runtime core. + /// + /// Service collection. + /// Service collection for fluent chaining. + public static IServiceCollection AddThalosDalRuntime(this IServiceCollection services) + { + services.AddBlueprintRuntimeCore(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Thalos.DAL/Health/DalDependencyHealthCheck.cs b/src/Thalos.DAL/Health/DalDependencyHealthCheck.cs new file mode 100644 index 0000000..e6a9f0e --- /dev/null +++ b/src/Thalos.DAL/Health/DalDependencyHealthCheck.cs @@ -0,0 +1,27 @@ +using Core.Blueprint.Common.Runtime; +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Health; + +/// +/// Default DAL dependency health check implementation. +/// +public sealed class DalDependencyHealthCheck(IBlueprintSystemClock clock) : IDalDependencyHealthCheck +{ + /// + public Task CheckAsync(CancellationToken cancellationToken = default) + { + var envelope = new IdentityContractEnvelope("1.0.0", $"corr-{clock.UtcNow:yyyyMMddHHmmssfff}"); + IReadOnlyList dependencyNames = + [ + "IUserDataProvider", + "IRoleDataProvider", + "IPermissionDataProvider", + "IModuleDataProvider", + "ITenantDataProvider" + ]; + + var status = new DalDependencyHealthStatus(envelope, true, dependencyNames); + return Task.FromResult(status); + } +} diff --git a/src/Thalos.DAL/Providers/InMemory/InMemoryModuleDataProvider.cs b/src/Thalos.DAL/Providers/InMemory/InMemoryModuleDataProvider.cs new file mode 100644 index 0000000..db80e20 --- /dev/null +++ b/src/Thalos.DAL/Providers/InMemory/InMemoryModuleDataProvider.cs @@ -0,0 +1,22 @@ +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Providers.InMemory; + +/// +/// In-memory provider for identity module lookup contracts. +/// +public sealed class InMemoryModuleDataProvider : IModuleDataProvider +{ + /// + public Task> ReadModulesAsync( + IdentityModuleLookupRequest request, + CancellationToken cancellationToken = default) + { + IReadOnlyList records = + [ + new IdentityModuleRecord(request.Envelope, "identity", true) + ]; + + return Task.FromResult(records); + } +} diff --git a/src/Thalos.DAL/Providers/InMemory/InMemoryPermissionDataProvider.cs b/src/Thalos.DAL/Providers/InMemory/InMemoryPermissionDataProvider.cs new file mode 100644 index 0000000..5cb7626 --- /dev/null +++ b/src/Thalos.DAL/Providers/InMemory/InMemoryPermissionDataProvider.cs @@ -0,0 +1,23 @@ +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Providers.InMemory; + +/// +/// In-memory provider for identity permission lookup contracts. +/// +public sealed class InMemoryPermissionDataProvider : IPermissionDataProvider +{ + /// + public Task> ReadPermissionsAsync( + IdentityPermissionSetLookupRequest request, + CancellationToken cancellationToken = default) + { + IReadOnlyList records = + [ + new IdentityPermissionRecord(request.Envelope, "identity.token.issue", "identity.admin"), + new IdentityPermissionRecord(request.Envelope, "identity.policy.evaluate", "identity.admin") + ]; + + return Task.FromResult(records); + } +} diff --git a/src/Thalos.DAL/Providers/InMemory/InMemoryRoleDataProvider.cs b/src/Thalos.DAL/Providers/InMemory/InMemoryRoleDataProvider.cs new file mode 100644 index 0000000..2b7f3ac --- /dev/null +++ b/src/Thalos.DAL/Providers/InMemory/InMemoryRoleDataProvider.cs @@ -0,0 +1,22 @@ +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Providers.InMemory; + +/// +/// In-memory provider for identity role lookup contracts. +/// +public sealed class InMemoryRoleDataProvider : IRoleDataProvider +{ + /// + public Task> ReadRolesAsync( + IdentityRoleLookupRequest request, + CancellationToken cancellationToken = default) + { + IReadOnlyList records = + [ + new IdentityRoleRecord(request.Envelope, "identity.admin", request.TenantId) + ]; + + return Task.FromResult(records); + } +} diff --git a/src/Thalos.DAL/Providers/InMemory/InMemoryTenantDataProvider.cs b/src/Thalos.DAL/Providers/InMemory/InMemoryTenantDataProvider.cs new file mode 100644 index 0000000..e61cef3 --- /dev/null +++ b/src/Thalos.DAL/Providers/InMemory/InMemoryTenantDataProvider.cs @@ -0,0 +1,23 @@ +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Providers.InMemory; + +/// +/// In-memory provider for identity tenant lookup contracts. +/// +public sealed class InMemoryTenantDataProvider : ITenantDataProvider +{ + /// + public Task ReadTenantAsync( + IdentityTenantLookupRequest request, + CancellationToken cancellationToken = default) + { + var record = new IdentityTenantRecord( + request.Envelope, + request.TenantId, + $"tenant-{request.TenantId}", + true); + + return Task.FromResult(record); + } +} diff --git a/src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs b/src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs new file mode 100644 index 0000000..4d54c23 --- /dev/null +++ b/src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs @@ -0,0 +1,28 @@ +using Thalos.DAL.Contracts; + +namespace Thalos.DAL.Providers.InMemory; + +/// +/// In-memory provider for identity user lookup contracts. +/// +public sealed class InMemoryUserDataProvider : 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, + "tenant-default", + "active"); + + return Task.FromResult(record); + } +} diff --git a/src/Thalos.DAL/Repositories/IdentityRepository.cs b/src/Thalos.DAL/Repositories/IdentityRepository.cs new file mode 100644 index 0000000..8725a0d --- /dev/null +++ b/src/Thalos.DAL/Repositories/IdentityRepository.cs @@ -0,0 +1,52 @@ +using Thalos.DAL.Contracts; +using Thalos.DAL.Providers; + +namespace Thalos.DAL.Repositories; + +/// +/// Default identity repository implementation composed from DAL providers. +/// +public sealed class IdentityRepository( + IUserDataProvider userDataProvider, + IPermissionDataProvider permissionDataProvider) : IIdentityRepository +{ + /// + public async Task ReadIdentityTokenAsync( + IdentityTokenLookupRequest request, + CancellationToken cancellationToken = default) + { + var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId); + var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken); + if (userRecord is null) + { + return null; + } + + var token = $"{request.SubjectId}:{request.TenantId}:token"; + return new IdentityTokenRecord(request.Envelope, request.SubjectId, request.TenantId, token, 1800); + } + + /// + public async Task ReadIdentityPolicyAsync( + IdentityPolicyLookupRequest request, + CancellationToken cancellationToken = default) + { + var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId); + var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken); + if (userRecord is null) + { + return null; + } + + var contextSatisfied = string.Equals(userRecord.Status, "active", StringComparison.OrdinalIgnoreCase); + return new IdentityPolicyRecord(request.Envelope, request.SubjectId, request.PermissionCode, contextSatisfied); + } + + /// + public Task> ReadPermissionSetAsync( + IdentityPermissionSetLookupRequest request, + CancellationToken cancellationToken = default) + { + return permissionDataProvider.ReadPermissionsAsync(request, cancellationToken); + } +} diff --git a/src/Thalos.DAL/Thalos.DAL.csproj b/src/Thalos.DAL/Thalos.DAL.csproj index 04a4bbc..bf5537a 100644 --- a/src/Thalos.DAL/Thalos.DAL.csproj +++ b/src/Thalos.DAL/Thalos.DAL.csproj @@ -5,6 +5,7 @@ enable + diff --git a/tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs b/tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs new file mode 100644 index 0000000..6c902fc --- /dev/null +++ b/tests/Thalos.DAL.UnitTests/RuntimeWiringTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using Thalos.DAL.Adapters; +using Thalos.DAL.Contracts; +using Thalos.DAL.DependencyInjection; +using Thalos.DAL.Health; +using Thalos.DAL.Repositories; + +namespace Thalos.DAL.UnitTests; + +public class RuntimeWiringTests +{ + [Fact] + public async Task AddThalosDalRuntime_WhenResolved_WiresRepositoryAndProviders() + { + 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-123"), + "user-1", + "tenant-1"); + + var response = await repository.ReadIdentityTokenAsync(request); + + Assert.NotNull(response); + Assert.Equal("user-1", response.SubjectId); + Assert.Equal("tenant-1", response.TenantId); + Assert.Equal(1800, response.ExpiresInSeconds); + } + + [Fact] + public void AddThalosDalRuntime_WhenResolved_WiresGrpcContractAdapter() + { + var services = new ServiceCollection(); + services.AddThalosDalRuntime(); + + using var provider = services.BuildServiceProvider(); + var adapter = provider.GetRequiredService(); + var grpcContract = new Thalos.DAL.Grpc.IdentityTokenDalGrpcContract("user-2", "tenant-2"); + + var request = adapter.FromGrpcTokenRequest(grpcContract); + + Assert.Equal("user-2", request.SubjectId); + Assert.Equal("tenant-2", request.TenantId); + Assert.NotEmpty(request.Envelope.CorrelationId); + } + + [Fact] + public async Task AddThalosDalRuntime_WhenResolved_WiresDependencyHealthCheck() + { + var services = new ServiceCollection(); + services.AddThalosDalRuntime(); + + using var provider = services.BuildServiceProvider(); + var healthCheck = provider.GetRequiredService(); + + var status = await healthCheck.CheckAsync(); + + Assert.True(status.IsHealthy); + Assert.Contains("IUserDataProvider", status.DependencyNames); + Assert.Contains("IPermissionDataProvider", status.DependencyNames); + } +} diff --git a/tests/Thalos.DAL.UnitTests/Thalos.DAL.UnitTests.csproj b/tests/Thalos.DAL.UnitTests/Thalos.DAL.UnitTests.csproj index 202abae..8f4a236 100644 --- a/tests/Thalos.DAL.UnitTests/Thalos.DAL.UnitTests.csproj +++ b/tests/Thalos.DAL.UnitTests/Thalos.DAL.UnitTests.csproj @@ -7,6 +7,7 @@ + From 16e5e0a68a864940c8933c4ffe5baf762ffdf36d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 24 Feb 2026 05:26:54 -0600 Subject: [PATCH 2/3] refactor(thalos-dal): keep repository technical --- docs/architecture/dal-domain-alignment.md | 13 +++++++++++++ docs/migration/dal-port-alignment-map.md | 6 ++++++ docs/migration/technical-mapping-rules.md | 6 ++++++ .../Contracts/IdentityUserLookupRequest.cs | 3 ++- .../Contracts/IdentityUserRecord.cs | 8 +++++++- .../InMemory/InMemoryUserDataProvider.cs | 7 +++++-- .../Repositories/IdentityRepository.cs | 19 +++++++++++++------ 7 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/dal-domain-alignment.md create mode 100644 docs/migration/dal-port-alignment-map.md create mode 100644 docs/migration/technical-mapping-rules.md diff --git a/docs/architecture/dal-domain-alignment.md b/docs/architecture/dal-domain-alignment.md new file mode 100644 index 0000000..1261cc9 --- /dev/null +++ b/docs/architecture/dal-domain-alignment.md @@ -0,0 +1,13 @@ +# Thalos DAL Domain Alignment + +## Goal +Align DAL with thalos-domain abstractions while keeping DAL technical. + +## DAL Responsibilities +- Identity persistence and retrieval +- Technical data translation +- Provider/repository boundaries + +## Prohibited +- Identity policy decision ownership +- Service orchestration concerns diff --git a/docs/migration/dal-port-alignment-map.md b/docs/migration/dal-port-alignment-map.md new file mode 100644 index 0000000..dbbc0be --- /dev/null +++ b/docs/migration/dal-port-alignment-map.md @@ -0,0 +1,6 @@ +# Thalos DAL Port Alignment Map + +## Alignment Areas +- DAL read/write ports map to domain contracts. +- Technical DTO translation remains in DAL adapters. +- Domain policy semantics are not reimplemented in DAL. diff --git a/docs/migration/technical-mapping-rules.md b/docs/migration/technical-mapping-rules.md new file mode 100644 index 0000000..98161ed --- /dev/null +++ b/docs/migration/technical-mapping-rules.md @@ -0,0 +1,6 @@ +# Thalos DAL Technical Mapping Rules + +## Rules +- Mapping logic remains technical and deterministic. +- No policy evaluation branching in DAL mapping layer. +- Correlation and metadata pass-through remains unchanged. diff --git a/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs b/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs index 8e6d4e4..9c5117f 100644 --- a/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs +++ b/src/Thalos.DAL/Contracts/IdentityUserLookupRequest.cs @@ -5,4 +5,5 @@ namespace Thalos.DAL.Contracts; /// /// Contract envelope metadata. /// Identity subject identifier. -public sealed record IdentityUserLookupRequest(IdentityContractEnvelope Envelope, string SubjectId); +/// Tenant identifier. +public sealed record IdentityUserLookupRequest(IdentityContractEnvelope Envelope, string SubjectId, string TenantId); diff --git a/src/Thalos.DAL/Contracts/IdentityUserRecord.cs b/src/Thalos.DAL/Contracts/IdentityUserRecord.cs index aa364ac..095f3c5 100644 --- a/src/Thalos.DAL/Contracts/IdentityUserRecord.cs +++ b/src/Thalos.DAL/Contracts/IdentityUserRecord.cs @@ -7,8 +7,14 @@ namespace Thalos.DAL.Contracts; /// Identity subject identifier. /// Tenant scope identifier. /// Current user status. +/// Persisted token projection for subject/tenant. +/// Persisted token expiration in seconds. +/// Persisted policy context projection. public sealed record IdentityUserRecord( IdentityContractEnvelope Envelope, string SubjectId, string TenantId, - string Status); + string Status, + string Token, + int ExpiresInSeconds, + bool ContextSatisfied); diff --git a/src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs b/src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs index 4d54c23..66cd15d 100644 --- a/src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs +++ b/src/Thalos.DAL/Providers/InMemory/InMemoryUserDataProvider.cs @@ -20,8 +20,11 @@ public sealed class InMemoryUserDataProvider : IUserDataProvider var record = new IdentityUserRecord( request.Envelope, request.SubjectId, - "tenant-default", - "active"); + request.TenantId, + "active", + $"{request.SubjectId}:{request.TenantId}:token", + 1800, + true); return Task.FromResult(record); } diff --git a/src/Thalos.DAL/Repositories/IdentityRepository.cs b/src/Thalos.DAL/Repositories/IdentityRepository.cs index 8725a0d..bbe768c 100644 --- a/src/Thalos.DAL/Repositories/IdentityRepository.cs +++ b/src/Thalos.DAL/Repositories/IdentityRepository.cs @@ -15,15 +15,19 @@ public sealed class IdentityRepository( IdentityTokenLookupRequest request, CancellationToken cancellationToken = default) { - var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId); + var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId, request.TenantId); var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken); if (userRecord is null) { return null; } - var token = $"{request.SubjectId}:{request.TenantId}:token"; - return new IdentityTokenRecord(request.Envelope, request.SubjectId, request.TenantId, token, 1800); + return new IdentityTokenRecord( + request.Envelope, + request.SubjectId, + request.TenantId, + userRecord.Token, + userRecord.ExpiresInSeconds); } /// @@ -31,15 +35,18 @@ public sealed class IdentityRepository( IdentityPolicyLookupRequest request, CancellationToken cancellationToken = default) { - var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId); + var userRequest = new IdentityUserLookupRequest(request.Envelope, request.SubjectId, request.TenantId); var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken); if (userRecord is null) { return null; } - var contextSatisfied = string.Equals(userRecord.Status, "active", StringComparison.OrdinalIgnoreCase); - return new IdentityPolicyRecord(request.Envelope, request.SubjectId, request.PermissionCode, contextSatisfied); + return new IdentityPolicyRecord( + request.Envelope, + request.SubjectId, + request.PermissionCode, + userRecord.ContextSatisfied); } /// 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 3/3] 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]