feat(thalos-dal): add provider-routed identity adapters

This commit is contained in:
José René White Enciso 2026-02-25 13:13:56 -06:00
parent 16e5e0a68a
commit 2d3f939e8f
15 changed files with 334 additions and 15 deletions

View File

@ -16,3 +16,7 @@
- Provider boundaries remain internal to Thalos DAL. - Provider boundaries remain internal to Thalos DAL.
- DAL interfaces expose only transport-neutral contracts and read ports. - DAL interfaces expose only transport-neutral contracts and read ports.
- Identity abstractions remain Thalos-owned. - Identity abstractions remain Thalos-owned.
- Runtime provider routes currently support:
- `InternalJwt`
- `AzureAd`
- `Google`

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.DAL.Contracts; namespace Thalos.DAL.Contracts;
/// <summary> /// <summary>
@ -6,7 +8,9 @@ namespace Thalos.DAL.Contracts;
/// <param name="Envelope">Contract envelope metadata.</param> /// <param name="Envelope">Contract envelope metadata.</param>
/// <param name="PermissionCode">Permission code identifier.</param> /// <param name="PermissionCode">Permission code identifier.</param>
/// <param name="SourceRoleCode">Role code that grants the permission.</param> /// <param name="SourceRoleCode">Role code that grants the permission.</param>
/// <param name="Provider">Auth provider for the permission grant.</param>
public sealed record IdentityPermissionRecord( public sealed record IdentityPermissionRecord(
IdentityContractEnvelope Envelope, IdentityContractEnvelope Envelope,
string PermissionCode, string PermissionCode,
string SourceRoleCode); string SourceRoleCode,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt);

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.DAL.Contracts; namespace Thalos.DAL.Contracts;
/// <summary> /// <summary>
@ -6,7 +8,9 @@ namespace Thalos.DAL.Contracts;
/// <param name="Envelope">Contract envelope metadata.</param> /// <param name="Envelope">Contract envelope metadata.</param>
/// <param name="SubjectId">Identity subject identifier.</param> /// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="TenantId">Tenant scope identifier.</param> /// <param name="TenantId">Tenant scope identifier.</param>
/// <param name="Provider">Auth provider for the lookup flow.</param>
public sealed record IdentityPermissionSetLookupRequest( public sealed record IdentityPermissionSetLookupRequest(
IdentityContractEnvelope Envelope, IdentityContractEnvelope Envelope,
string SubjectId, string SubjectId,
string TenantId); string TenantId,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt);

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.DAL.Contracts; namespace Thalos.DAL.Contracts;
/// <summary> /// <summary>
@ -7,8 +9,10 @@ namespace Thalos.DAL.Contracts;
/// <param name="SubjectId">Identity subject identifier.</param> /// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="TenantId">Tenant scope identifier.</param> /// <param name="TenantId">Tenant scope identifier.</param>
/// <param name="PermissionCode">Permission code to evaluate.</param> /// <param name="PermissionCode">Permission code to evaluate.</param>
/// <param name="Provider">Auth provider for the lookup flow.</param>
public sealed record IdentityPolicyLookupRequest( public sealed record IdentityPolicyLookupRequest(
IdentityContractEnvelope Envelope, IdentityContractEnvelope Envelope,
string SubjectId, string SubjectId,
string TenantId, string TenantId,
string PermissionCode); string PermissionCode,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt);

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.DAL.Contracts; namespace Thalos.DAL.Contracts;
/// <summary> /// <summary>
@ -7,8 +9,10 @@ namespace Thalos.DAL.Contracts;
/// <param name="SubjectId">Identity subject identifier.</param> /// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="PermissionCode">Permission code evaluated.</param> /// <param name="PermissionCode">Permission code evaluated.</param>
/// <param name="ContextSatisfied">Indicates whether policy context is satisfied.</param> /// <param name="ContextSatisfied">Indicates whether policy context is satisfied.</param>
/// <param name="Provider">Auth provider used for policy evaluation.</param>
public sealed record IdentityPolicyRecord( public sealed record IdentityPolicyRecord(
IdentityContractEnvelope Envelope, IdentityContractEnvelope Envelope,
string SubjectId, string SubjectId,
string PermissionCode, string PermissionCode,
bool ContextSatisfied); bool ContextSatisfied,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt);

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.DAL.Contracts; namespace Thalos.DAL.Contracts;
/// <summary> /// <summary>
@ -6,4 +8,11 @@ namespace Thalos.DAL.Contracts;
/// <param name="Envelope">Contract envelope metadata.</param> /// <param name="Envelope">Contract envelope metadata.</param>
/// <param name="SubjectId">Identity subject identifier.</param> /// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="TenantId">Tenant scope identifier.</param> /// <param name="TenantId">Tenant scope identifier.</param>
public sealed record IdentityTokenLookupRequest(IdentityContractEnvelope Envelope, string SubjectId, string TenantId); /// <param name="Provider">Auth provider for the lookup flow.</param>
/// <param name="ExternalToken">External provider token when applicable.</param>
public sealed record IdentityTokenLookupRequest(
IdentityContractEnvelope Envelope,
string SubjectId,
string TenantId,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt,
string ExternalToken = "");

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.DAL.Contracts; namespace Thalos.DAL.Contracts;
/// <summary> /// <summary>
@ -8,9 +10,11 @@ namespace Thalos.DAL.Contracts;
/// <param name="TenantId">Tenant scope identifier.</param> /// <param name="TenantId">Tenant scope identifier.</param>
/// <param name="Token">Issued access token value.</param> /// <param name="Token">Issued access token value.</param>
/// <param name="ExpiresInSeconds">Token expiration in seconds.</param> /// <param name="ExpiresInSeconds">Token expiration in seconds.</param>
/// <param name="Provider">Auth provider used for token issuance.</param>
public sealed record IdentityTokenRecord( public sealed record IdentityTokenRecord(
IdentityContractEnvelope Envelope, IdentityContractEnvelope Envelope,
string SubjectId, string SubjectId,
string TenantId, string TenantId,
string Token, string Token,
int ExpiresInSeconds); int ExpiresInSeconds,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt);

View File

@ -1,3 +1,5 @@
using BuildingBlock.Identity.Contracts.Conventions;
namespace Thalos.DAL.Contracts; namespace Thalos.DAL.Contracts;
/// <summary> /// <summary>
@ -6,4 +8,11 @@ namespace Thalos.DAL.Contracts;
/// <param name="Envelope">Contract envelope metadata.</param> /// <param name="Envelope">Contract envelope metadata.</param>
/// <param name="SubjectId">Identity subject identifier.</param> /// <param name="SubjectId">Identity subject identifier.</param>
/// <param name="TenantId">Tenant identifier.</param> /// <param name="TenantId">Tenant identifier.</param>
public sealed record IdentityUserLookupRequest(IdentityContractEnvelope Envelope, string SubjectId, string TenantId); /// <param name="Provider">Auth provider for the lookup flow.</param>
/// <param name="ExternalToken">External provider token when applicable.</param>
public sealed record IdentityUserLookupRequest(
IdentityContractEnvelope Envelope,
string SubjectId,
string TenantId,
IdentityAuthProvider Provider = IdentityAuthProvider.InternalJwt,
string ExternalToken = "");

View File

@ -23,9 +23,17 @@ public static class ThalosDalServiceCollectionExtensions
{ {
services.AddBlueprintRuntimeCore(); services.AddBlueprintRuntimeCore();
services.TryAddSingleton<IUserDataProvider, InMemoryUserDataProvider>(); services.TryAddSingleton<InternalJwtUserDataProvider>();
services.TryAddSingleton<AzureAdUserDataProvider>();
services.TryAddSingleton<GoogleUserDataProvider>();
services.TryAddSingleton<IUserDataProvider, RoutedUserDataProvider>();
services.TryAddSingleton<InternalJwtPermissionDataProvider>();
services.TryAddSingleton<AzureAdPermissionDataProvider>();
services.TryAddSingleton<GooglePermissionDataProvider>();
services.TryAddSingleton<IPermissionDataProvider, RoutedPermissionDataProvider>();
services.TryAddSingleton<IRoleDataProvider, InMemoryRoleDataProvider>(); services.TryAddSingleton<IRoleDataProvider, InMemoryRoleDataProvider>();
services.TryAddSingleton<IPermissionDataProvider, InMemoryPermissionDataProvider>();
services.TryAddSingleton<IModuleDataProvider, InMemoryModuleDataProvider>(); services.TryAddSingleton<IModuleDataProvider, InMemoryModuleDataProvider>();
services.TryAddSingleton<ITenantDataProvider, InMemoryTenantDataProvider>(); services.TryAddSingleton<ITenantDataProvider, InMemoryTenantDataProvider>();

View File

@ -0,0 +1,89 @@
using BuildingBlock.Identity.Contracts.Conventions;
using Thalos.DAL.Contracts;
namespace Thalos.DAL.Providers;
/// <summary>
/// Internal JWT permission provider implementation.
/// </summary>
public sealed class InternalJwtPermissionDataProvider : IPermissionDataProvider
{
/// <inheritdoc />
public Task<IReadOnlyList<IdentityPermissionRecord>> ReadPermissionsAsync(
IdentityPermissionSetLookupRequest request,
CancellationToken cancellationToken = default)
{
IReadOnlyList<IdentityPermissionRecord> 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);
}
}
/// <summary>
/// Azure AD permission provider implementation.
/// </summary>
public sealed class AzureAdPermissionDataProvider : IPermissionDataProvider
{
/// <inheritdoc />
public Task<IReadOnlyList<IdentityPermissionRecord>> ReadPermissionsAsync(
IdentityPermissionSetLookupRequest request,
CancellationToken cancellationToken = default)
{
IReadOnlyList<IdentityPermissionRecord> 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);
}
}
/// <summary>
/// Google permission provider implementation.
/// </summary>
public sealed class GooglePermissionDataProvider : IPermissionDataProvider
{
/// <inheritdoc />
public Task<IReadOnlyList<IdentityPermissionRecord>> ReadPermissionsAsync(
IdentityPermissionSetLookupRequest request,
CancellationToken cancellationToken = default)
{
IReadOnlyList<IdentityPermissionRecord> 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);
}
}
/// <summary>
/// Routes permission lookups to the matching provider implementation.
/// </summary>
public sealed class RoutedPermissionDataProvider(
InternalJwtPermissionDataProvider internalJwtProvider,
AzureAdPermissionDataProvider azureProvider,
GooglePermissionDataProvider googleProvider) : IPermissionDataProvider
{
/// <inheritdoc />
public Task<IReadOnlyList<IdentityPermissionRecord>> 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<IReadOnlyList<IdentityPermissionRecord>>([])
};
}
}

View File

@ -0,0 +1,143 @@
using BuildingBlock.Identity.Contracts.Conventions;
using Thalos.DAL.Contracts;
namespace Thalos.DAL.Providers;
/// <summary>
/// Internal JWT provider implementation for identity user reads.
/// </summary>
public sealed class InternalJwtUserDataProvider : IUserDataProvider
{
/// <inheritdoc />
public Task<IdentityUserRecord?> ReadUserAsync(
IdentityUserLookupRequest request,
CancellationToken cancellationToken = default)
{
if (request.SubjectId.StartsWith("missing-", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<IdentityUserRecord?>(null);
}
var record = new IdentityUserRecord(
request.Envelope,
request.SubjectId,
request.TenantId,
"active",
$"{request.SubjectId}:{request.TenantId}:token",
1800,
true);
return Task.FromResult<IdentityUserRecord?>(record);
}
}
/// <summary>
/// Azure AD provider implementation for identity user reads.
/// </summary>
public sealed class AzureAdUserDataProvider : IUserDataProvider
{
/// <inheritdoc />
public Task<IdentityUserRecord?> ReadUserAsync(
IdentityUserLookupRequest request,
CancellationToken cancellationToken = default)
{
var subjectId = ResolveSubjectId(request, "azure-sub");
if (string.IsNullOrWhiteSpace(subjectId))
{
return Task.FromResult<IdentityUserRecord?>(null);
}
var record = new IdentityUserRecord(
request.Envelope,
subjectId,
request.TenantId,
"active",
$"azure:{subjectId}:{request.TenantId}:token",
3600,
true);
return Task.FromResult<IdentityUserRecord?>(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))}";
}
}
/// <summary>
/// Google provider implementation for identity user reads.
/// </summary>
public sealed class GoogleUserDataProvider : IUserDataProvider
{
/// <inheritdoc />
public Task<IdentityUserRecord?> ReadUserAsync(
IdentityUserLookupRequest request,
CancellationToken cancellationToken = default)
{
var subjectId = ResolveSubjectId(request, "google-sub");
if (string.IsNullOrWhiteSpace(subjectId))
{
return Task.FromResult<IdentityUserRecord?>(null);
}
var record = new IdentityUserRecord(
request.Envelope,
subjectId,
request.TenantId,
"active",
$"google:{subjectId}:{request.TenantId}:token",
3000,
true);
return Task.FromResult<IdentityUserRecord?>(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))}";
}
}
/// <summary>
/// Routes user lookups to the matching provider implementation.
/// </summary>
public sealed class RoutedUserDataProvider(
InternalJwtUserDataProvider internalJwtProvider,
AzureAdUserDataProvider azureProvider,
GoogleUserDataProvider googleProvider) : IUserDataProvider
{
/// <inheritdoc />
public Task<IdentityUserRecord?> 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<IdentityUserRecord?>(null)
};
}
}

View File

@ -15,7 +15,12 @@ public sealed class IdentityRepository(
IdentityTokenLookupRequest request, IdentityTokenLookupRequest request,
CancellationToken cancellationToken = default) 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); var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken);
if (userRecord is null) if (userRecord is null)
{ {
@ -24,10 +29,11 @@ public sealed class IdentityRepository(
return new IdentityTokenRecord( return new IdentityTokenRecord(
request.Envelope, request.Envelope,
request.SubjectId, userRecord.SubjectId,
request.TenantId, request.TenantId,
userRecord.Token, userRecord.Token,
userRecord.ExpiresInSeconds); userRecord.ExpiresInSeconds,
request.Provider);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -35,7 +41,11 @@ public sealed class IdentityRepository(
IdentityPolicyLookupRequest request, IdentityPolicyLookupRequest request,
CancellationToken cancellationToken = default) 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); var userRecord = await userDataProvider.ReadUserAsync(userRequest, cancellationToken);
if (userRecord is null) if (userRecord is null)
{ {
@ -44,9 +54,10 @@ public sealed class IdentityRepository(
return new IdentityPolicyRecord( return new IdentityPolicyRecord(
request.Envelope, request.Envelope,
request.SubjectId, userRecord.SubjectId,
request.PermissionCode, request.PermissionCode,
userRecord.ContextSatisfied); userRecord.ContextSatisfied,
request.Provider);
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@ -6,6 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\..\..\building-block-identity\src\BuildingBlock.Identity.Contracts\BuildingBlock.Identity.Contracts.csproj" />
<ProjectReference Include="..\..\..\blueprint-platform\src\Core.Blueprint.Common\Core.Blueprint.Common.csproj" /> <ProjectReference Include="..\..\..\blueprint-platform\src\Core.Blueprint.Common\Core.Blueprint.Common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -16,6 +16,7 @@ public class ContractShapeTests
Assert.Equal("user-1", request.SubjectId); Assert.Equal("user-1", request.SubjectId);
Assert.Equal("tenant-1", request.TenantId); Assert.Equal("tenant-1", request.TenantId);
Assert.Equal("identity.token.issue", request.PermissionCode); Assert.Equal("identity.token.issue", request.PermissionCode);
Assert.Equal(BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.InternalJwt, request.Provider);
} }
[Fact] [Fact]
@ -30,6 +31,7 @@ public class ContractShapeTests
Assert.Equal("tenant-1", record.TenantId); Assert.Equal("tenant-1", record.TenantId);
Assert.Equal("token-xyz", record.Token); Assert.Equal("token-xyz", record.Token);
Assert.Equal(1800, record.ExpiresInSeconds); Assert.Equal(1800, record.ExpiresInSeconds);
Assert.Equal(BuildingBlock.Identity.Contracts.Conventions.IdentityAuthProvider.InternalJwt, record.Provider);
} }
[Fact] [Fact]

View File

@ -28,6 +28,29 @@ public class RuntimeWiringTests
Assert.Equal("user-1", response.SubjectId); Assert.Equal("user-1", response.SubjectId);
Assert.Equal("tenant-1", response.TenantId); Assert.Equal("tenant-1", response.TenantId);
Assert.Equal(1800, response.ExpiresInSeconds); 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<IIdentityRepository>();
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] [Fact]