diff --git a/src/Thalos.Bff.Application/Adapters/IdentityEdgeContractAdapter.cs b/src/Thalos.Bff.Application/Adapters/IdentityEdgeContractAdapter.cs new file mode 100644 index 0000000..508c98e --- /dev/null +++ b/src/Thalos.Bff.Application/Adapters/IdentityEdgeContractAdapter.cs @@ -0,0 +1,41 @@ +using Thalos.Bff.Application.Contracts; +using Thalos.Bff.Contracts.Api; +using Thalos.Service.Identity.Abstractions.Contracts; + +namespace Thalos.Bff.Application.Adapters; + +/// +/// Default adapter implementation for identity edge API and service contracts. +/// +public sealed class IdentityEdgeContractAdapter : IIdentityEdgeContractAdapter +{ + /// + public EvaluateIdentityPolicyRequest ToPolicyRequest(IssueTokenApiRequest request, string permissionCode) + { + return new EvaluateIdentityPolicyRequest(request.SubjectId, request.TenantId, permissionCode); + } + + /// + public IssueIdentityTokenRequest ToIssueTokenRequest(IssueTokenApiRequest request) + { + return new IssueIdentityTokenRequest(request.SubjectId, request.TenantId); + } + + /// + public IssueTokenApiResponse ToIssueTokenApiResponse(IssueIdentityTokenResponse response) + { + return new IssueTokenApiResponse(response.Token, response.ExpiresInSeconds); + } + + /// + public RefreshIdentitySessionRequest ToRefreshSessionRequest(RefreshSessionApiRequest request) + { + return new RefreshIdentitySessionRequest(request.RefreshToken, request.CorrelationId); + } + + /// + public RefreshSessionApiResponse ToRefreshSessionApiResponse(RefreshIdentitySessionResponse response) + { + return new RefreshSessionApiResponse(response.Token, response.ExpiresInSeconds); + } +} diff --git a/src/Thalos.Bff.Application/Adapters/IdentityEdgeGrpcContractAdapter.cs b/src/Thalos.Bff.Application/Adapters/IdentityEdgeGrpcContractAdapter.cs new file mode 100644 index 0000000..c06c1ee --- /dev/null +++ b/src/Thalos.Bff.Application/Adapters/IdentityEdgeGrpcContractAdapter.cs @@ -0,0 +1,22 @@ +using Thalos.Bff.Application.Grpc; +using Thalos.Bff.Contracts.Api; + +namespace Thalos.Bff.Application.Adapters; + +/// +/// Default adapter implementation for identity edge gRPC contract translation. +/// +public sealed class IdentityEdgeGrpcContractAdapter : IIdentityEdgeGrpcContractAdapter +{ + /// + public IssueIdentityTokenGrpcContract ToGrpc(IssueTokenApiRequest request) + { + return new IssueIdentityTokenGrpcContract(request.SubjectId, request.TenantId, request.CorrelationId); + } + + /// + public IssueTokenApiRequest FromGrpc(IssueIdentityTokenGrpcContract contract) + { + return new IssueTokenApiRequest(contract.SubjectId, contract.TenantId, contract.CorrelationId); + } +} diff --git a/src/Thalos.Bff.Application/DependencyInjection/ThalosBffApplicationServiceCollectionExtensions.cs b/src/Thalos.Bff.Application/DependencyInjection/ThalosBffApplicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..49f8db4 --- /dev/null +++ b/src/Thalos.Bff.Application/DependencyInjection/ThalosBffApplicationServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Thalos.Bff.Application.Adapters; +using Thalos.Bff.Application.Handlers; +using Thalos.Bff.Application.Security; + +namespace Thalos.Bff.Application.DependencyInjection; + +/// +/// Registers application-layer runtime wiring for thalos-bff. +/// +public static class ThalosBffApplicationServiceCollectionExtensions +{ + /// + /// Adds thalos-bff application handlers and adapter implementations. + /// + /// Service collection. + /// Service collection for fluent chaining. + public static IServiceCollection AddThalosBffApplicationRuntime(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } +} diff --git a/src/Thalos.Bff.Application/Security/IdentityPermissionGuard.cs b/src/Thalos.Bff.Application/Security/IdentityPermissionGuard.cs new file mode 100644 index 0000000..964e64c --- /dev/null +++ b/src/Thalos.Bff.Application/Security/IdentityPermissionGuard.cs @@ -0,0 +1,15 @@ +using Thalos.Service.Identity.Abstractions.Contracts; + +namespace Thalos.Bff.Application.Security; + +/// +/// Default permission guard backed by thalos-service policy evaluation responses. +/// +public sealed class IdentityPermissionGuard : IPermissionGuard +{ + /// + public bool CanAccess(EvaluateIdentityPolicyResponse policyResponse) + { + return policyResponse.IsAllowed; + } +} diff --git a/src/Thalos.Bff.Application/Thalos.Bff.Application.csproj b/src/Thalos.Bff.Application/Thalos.Bff.Application.csproj index 8edcb4b..206b1cc 100644 --- a/src/Thalos.Bff.Application/Thalos.Bff.Application.csproj +++ b/src/Thalos.Bff.Application/Thalos.Bff.Application.csproj @@ -5,6 +5,7 @@ enable + diff --git a/src/Thalos.Bff.Rest/Adapters/ThalosServiceGrpcClientAdapter.cs b/src/Thalos.Bff.Rest/Adapters/ThalosServiceGrpcClientAdapter.cs new file mode 100644 index 0000000..462f524 --- /dev/null +++ b/src/Thalos.Bff.Rest/Adapters/ThalosServiceGrpcClientAdapter.cs @@ -0,0 +1,107 @@ +using Grpc.Core; +using Microsoft.Extensions.Primitives; +using Thalos.Bff.Application.Adapters; +using Thalos.Bff.Application.Contracts; +using Thalos.Service.Grpc; +using Thalos.Service.Identity.Abstractions.Contracts; + +namespace Thalos.Bff.Rest.Adapters; + +/// +/// gRPC-backed adapter for downstream thalos-service calls. +/// +public sealed class ThalosServiceGrpcClientAdapter( + IdentityRuntime.IdentityRuntimeClient grpcClient, + IHttpContextAccessor httpContextAccessor, + IConfiguration configuration) : IThalosServiceClient +{ + private const string CorrelationHeaderName = "x-correlation-id"; + private readonly string refreshTenantId = configuration["ThalosService:RefreshTenantId"] ?? "refresh"; + + /// + public async Task IssueTokenAsync(IssueIdentityTokenRequest request) + { + var correlationId = ResolveCorrelationId(); + var grpcRequest = new IssueIdentityTokenGrpcRequest + { + SubjectId = request.SubjectId, + TenantId = request.TenantId + }; + + var grpcResponse = await grpcClient.IssueIdentityTokenAsync( + grpcRequest, + headers: CreateHeaders(correlationId)); + + return new IssueIdentityTokenResponse(grpcResponse.Token, grpcResponse.ExpiresInSeconds); + } + + /// + public async Task EvaluatePolicyAsync(EvaluateIdentityPolicyRequest request) + { + var correlationId = ResolveCorrelationId(); + var grpcRequest = new EvaluateIdentityPolicyGrpcRequest + { + SubjectId = request.SubjectId, + TenantId = request.TenantId, + PermissionCode = request.PermissionCode + }; + + var grpcResponse = await grpcClient.EvaluateIdentityPolicyAsync( + grpcRequest, + headers: CreateHeaders(correlationId)); + + return new EvaluateIdentityPolicyResponse( + grpcResponse.SubjectId, + grpcResponse.PermissionCode, + grpcResponse.IsAllowed); + } + + /// + public async Task RefreshSessionAsync(RefreshIdentitySessionRequest request) + { + var correlationId = ResolveCorrelationId(request.CorrelationId); + var grpcRequest = new IssueIdentityTokenGrpcRequest + { + SubjectId = request.RefreshToken, + TenantId = refreshTenantId + }; + + var grpcResponse = await grpcClient.IssueIdentityTokenAsync( + grpcRequest, + headers: CreateHeaders(correlationId)); + + return new RefreshIdentitySessionResponse(grpcResponse.Token, grpcResponse.ExpiresInSeconds); + } + + private string ResolveCorrelationId(string? preferred = null) + { + if (!string.IsNullOrWhiteSpace(preferred)) + { + return preferred; + } + + var context = httpContextAccessor.HttpContext; + if (context?.Items.TryGetValue(CorrelationHeaderName, out var itemValue) == true && + itemValue is string itemCorrelationId && + !string.IsNullOrWhiteSpace(itemCorrelationId)) + { + return itemCorrelationId; + } + + if (context?.Request.Headers.TryGetValue(CorrelationHeaderName, out var headerValue) == true && + !StringValues.IsNullOrEmpty(headerValue)) + { + return headerValue.ToString(); + } + + return context?.TraceIdentifier ?? $"corr-{Guid.NewGuid():N}"; + } + + private static Metadata CreateHeaders(string correlationId) + { + return new Metadata + { + { CorrelationHeaderName, correlationId } + }; + } +} diff --git a/src/Thalos.Bff.Rest/Program.cs b/src/Thalos.Bff.Rest/Program.cs index df15eb4..3944eee 100644 --- a/src/Thalos.Bff.Rest/Program.cs +++ b/src/Thalos.Bff.Rest/Program.cs @@ -1,18 +1,93 @@ +using Core.Blueprint.Common.DependencyInjection; +using Microsoft.Extensions.Primitives; +using Thalos.Bff.Application.Adapters; +using Thalos.Bff.Application.DependencyInjection; +using Thalos.Bff.Application.Handlers; using Thalos.Bff.Contracts.Api; +using Thalos.Bff.Rest.Adapters; +using Thalos.Bff.Rest.Endpoints; +using Thalos.Service.Grpc; + +const string CorrelationHeaderName = "x-correlation-id"; var builder = WebApplication.CreateBuilder(args); -// Stage 3 skeleton: single active external protocol for this deployment is REST. +builder.Services.AddHttpContextAccessor(); +builder.Services.AddHealthChecks(); +builder.Services.AddBlueprintRuntimeCore(); +builder.Services.AddThalosBffApplicationRuntime(); +builder.Services.AddScoped(); +builder.Services.AddGrpcClient(options => +{ + var serviceAddress = builder.Configuration["ThalosService:GrpcAddress"] ?? "http://localhost:5251"; + options.Address = new Uri(serviceAddress); +}); + var app = builder.Build(); -app.MapPost("/api/identity/token", (IssueTokenApiRequest request) => +app.Use(async (context, next) => { - return Results.Ok(new IssueTokenApiResponse("", 0)); + var correlationId = ResolveCorrelationId(context); + context.Items[CorrelationHeaderName] = correlationId; + context.Request.Headers[CorrelationHeaderName] = correlationId; + context.Response.Headers[CorrelationHeaderName] = correlationId; + await next(); }); -app.MapPost("/api/identity/session/refresh", (RefreshSessionApiRequest request) => +app.MapPost($"{EndpointConventions.ApiPrefix}/token", async ( + IssueTokenApiRequest request, + HttpContext context, + IIssueTokenHandler handler) => { - return Results.Ok(new RefreshSessionApiResponse("", 0)); + var normalizedRequest = request with { CorrelationId = ResolveCorrelationId(context, request.CorrelationId) }; + + try + { + var response = await handler.HandleAsync(normalizedRequest); + return Results.Ok(response); + } + catch (UnauthorizedAccessException) + { + return Results.Unauthorized(); + } }); +app.MapPost($"{EndpointConventions.ApiPrefix}/session/refresh", async ( + RefreshSessionApiRequest request, + HttpContext context, + IRefreshSessionHandler handler) => +{ + var normalizedRequest = request with { CorrelationId = ResolveCorrelationId(context, request.CorrelationId) }; + var response = await handler.HandleAsync(normalizedRequest); + return Results.Ok(response); +}); + +app.MapHealthChecks("/healthz"); + app.Run(); + +string ResolveCorrelationId(HttpContext context, string? preferred = null) +{ + if (!string.IsNullOrWhiteSpace(preferred)) + { + context.Items[CorrelationHeaderName] = preferred; + context.Request.Headers[CorrelationHeaderName] = preferred; + context.Response.Headers[CorrelationHeaderName] = preferred; + return preferred; + } + + if (context.Items.TryGetValue(CorrelationHeaderName, out var itemValue) && + itemValue is string itemCorrelationId && + !string.IsNullOrWhiteSpace(itemCorrelationId)) + { + return itemCorrelationId; + } + + if (context.Request.Headers.TryGetValue(CorrelationHeaderName, out var headerValue) && + !StringValues.IsNullOrEmpty(headerValue)) + { + return headerValue.ToString(); + } + + return context.TraceIdentifier; +} diff --git a/src/Thalos.Bff.Rest/Thalos.Bff.Rest.csproj b/src/Thalos.Bff.Rest/Thalos.Bff.Rest.csproj index 256722a..5188257 100644 --- a/src/Thalos.Bff.Rest/Thalos.Bff.Rest.csproj +++ b/src/Thalos.Bff.Rest/Thalos.Bff.Rest.csproj @@ -4,8 +4,22 @@ enable enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + +