diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index d8f60753..9dea56ba 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -117,20 +117,28 @@ services: - "traefik.http.services.storage-service.loadbalancer.healthcheck.interval=10s" - # Social Service .NET - Social Graph Management - social-service: + # Membership Service .NET - Membership Management + membership-service-net: build: - context: ../.. - dockerfile: services/social-service-net/Dockerfile - container_name: social-service-local + context: ../../services/membership-service-net + dockerfile: Dockerfile + image: goodgo/membership-service-net:latest + container_name: membership-service-net-local environment: - ASPNETCORE_ENVIRONMENT=Development - - ConnectionStrings__DefaultConnection=${SOCIAL_DATABASE_URL:-Host=localhost;Port=5432;Database=social_db;Username=postgres;Password=postgres} + - ASPNETCORE_URLS=http://+:8080 + # EN: Database - Neon PostgreSQL + # VI: Cơ sở dữ liệu - Neon PostgreSQL + - ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=membership_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require + # EN: IAM Service Communication + # VI: Giao tiếp IAM Service - IamService__BaseUrl=http://iam-service-net:8080 - - IamService__ServiceName=social-service + - IamService__ServiceName=membership-service ports: - "5003:8080" depends_on: + iam-service-net: + condition: service_healthy traefik: condition: service_started networks: @@ -144,11 +152,11 @@ services: start_period: 40s labels: - "traefik.enable=true" - - "traefik.http.routers.social-service.rule=PathPrefix(`/api/v1/relationships`) || PathPrefix(`/api/v1/blocks`)" - - "traefik.http.routers.social-service.entrypoints=web" - - "traefik.http.services.social-service.loadbalancer.server.port=8080" - - "traefik.http.services.social-service.loadbalancer.healthcheck.path=/health/live" - - "traefik.http.services.social-service.loadbalancer.healthcheck.interval=10s" + - "traefik.http.routers.membership-service.rule=PathPrefix(`/api/v1/memberships`) || PathPrefix(`/api/v1/subscriptions`)" + - "traefik.http.routers.membership-service.entrypoints=web" + - "traefik.http.services.membership-service.loadbalancer.server.port=8080" + - "traefik.http.services.membership-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.membership-service.loadbalancer.healthcheck.interval=10s" # IAM Service .NET - Identity and Access Management (Duende IdentityServer) iam-service-net: diff --git a/services/membership-service-net/docker-compose.yml b/services/membership-service-net/docker-compose.yml deleted file mode 100644 index 254ceb12..00000000 --- a/services/membership-service-net/docker-compose.yml +++ /dev/null @@ -1,72 +0,0 @@ -version: '3.8' - -# EN: Docker Compose for local development -# VI: Docker Compose cho phát triển local - -services: - myservice-api: - build: - context: . - dockerfile: Dockerfile - container_name: myservice-api - ports: - - "5000:8080" - environment: - - ASPNETCORE_ENVIRONMENT=Development - - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres - - REDIS_URL=redis:6379 - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - networks: - - myservice-network - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - - postgres: - image: postgres:16-alpine - container_name: myservice-postgres - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: myservice_db - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - myservice-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - container_name: myservice-redis - ports: - - "6379:6379" - volumes: - - redis_data:/data - networks: - - myservice-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - postgres_data: - redis_data: - -networks: - myservice-network: - driver: bridge diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs index 0d72623e..e521bcf6 100644 --- a/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs +++ b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MembershipService.Domain.AggregatesModel.MemberAggregate; +using MembershipService.Infrastructure.ExternalServices; using MembershipService.Infrastructure.Idempotency; using MembershipService.Infrastructure.Repositories; @@ -52,6 +53,28 @@ public static class DependencyInjection // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); + // EN: Configure IAM Service Client / VI: Cấu hình IAM Service Client + services.Configure( + configuration.GetSection(IamServiceSettings.SectionName)); + + // EN: Register HttpClient for IAM Service / VI: Đăng ký HttpClient cho IAM Service + services.AddHttpClient((sp, client) => + { + var settings = configuration.GetSection(IamServiceSettings.SectionName) + .Get() ?? new IamServiceSettings(); + + client.BaseAddress = new Uri(settings.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(settings.TimeoutSeconds); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + // EN: Allow self-signed certificates in development + // VI: Cho phép self-signed certificates trong development + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + return services; } } diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/ExternalServices/HttpIamServiceClient.cs b/services/membership-service-net/src/MembershipService.Infrastructure/ExternalServices/HttpIamServiceClient.cs new file mode 100644 index 00000000..828ebe19 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/ExternalServices/HttpIamServiceClient.cs @@ -0,0 +1,433 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MembershipService.Infrastructure.ExternalServices; + +/// +/// EN: HTTP client for communicating with IAM Service with caching and health check. +/// VI: HTTP client để giao tiếp với IAM Service với caching và health check. +/// +public class HttpIamServiceClient : IIamServiceClient +{ + private readonly HttpClient _httpClient; + private readonly IamServiceSettings _settings; + private readonly ILogger _logger; + + // EN: In-memory cache for user info / VI: Cache in-memory cho user info + private readonly ConcurrentDictionary> _userCache = new(); + private readonly ConcurrentDictionary>> _rolesCache = new(); + private readonly ConcurrentDictionary>> _permissionsCache = new(); + + // EN: Health check cache / VI: Cache health check + private CachedItem? _healthCache; + private readonly object _healthLock = new(); + + public HttpIamServiceClient( + HttpClient httpClient, + IOptions settings, + ILogger logger) + { + _httpClient = httpClient; + _settings = settings.Value; + _logger = logger; + + _httpClient.BaseAddress = new Uri(_settings.BaseUrl); + _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); + } + + // ============================================ + // EN: User Operations / VI: Thao tác User + // ============================================ + + /// + public async Task ValidateUserAsync( + string accessToken, + CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"token:{accessToken.GetHashCode()}"; + if (TryGetFromCache(_userCache, cacheKey, out var cached)) + { + _logger.LogDebug("User info retrieved from cache"); + return cached; + } + + try + { + var request = CreateRequest(HttpMethod.Get, "/api/v1/users/me", accessToken); + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to validate user token. Status: {StatusCode}", response.StatusCode); + return null; + } + + var userResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (userResponse?.Data == null) + return null; + + var userInfo = new IamUserInfo( + userResponse.Data.Id, + userResponse.Data.Email, + userResponse.Data.DisplayName, + true, + userResponse.Data.Roles ?? new List(), + userResponse.Data.Permissions); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_userCache, cacheKey, userInfo, _settings.CacheDurationSeconds); + + return userInfo; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating user with IAM Service"); + return null; + } + } + + /// + public async Task GetUserByIdAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"user:{userId}"; + if (TryGetFromCache(_userCache, cacheKey, out var cached)) + { + _logger.LogDebug("User {UserId} retrieved from cache", userId); + return cached; + } + + try + { + var request = CreateRequest(HttpMethod.Get, $"/api/v1/users/{userId}", accessToken); + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get user {UserId}. Status: {StatusCode}", userId, response.StatusCode); + return null; + } + + var userResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (userResponse?.Data == null) + return null; + + var userInfo = new IamUserInfo( + userResponse.Data.Id, + userResponse.Data.Email, + userResponse.Data.DisplayName, + userResponse.Data.IsActive, + userResponse.Data.Roles ?? new List(), + userResponse.Data.Permissions); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_userCache, cacheKey, userInfo, _settings.CacheDurationSeconds); + + return userInfo; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting user {UserId} from IAM Service", userId); + return null; + } + } + + /// + public async Task UserExistsAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + var user = await GetUserByIdAsync(userId, accessToken, cancellationToken); + return user != null; + } + + /// + public async Task> GetUserRolesAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"roles:{userId}"; + if (TryGetFromCache(_rolesCache, cacheKey, out var cached)) + { + return cached; + } + + try + { + var request = CreateRequest(HttpMethod.Get, $"/api/v1/users/{userId}/roles", accessToken); + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get roles for user {UserId}. Status: {StatusCode}", userId, response.StatusCode); + return Array.Empty(); + } + + var rolesResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + var roles = rolesResponse?.Data ?? Array.Empty(); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_rolesCache, cacheKey, roles, _settings.CacheDurationSeconds); + + return roles; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting roles for user {UserId}", userId); + return Array.Empty(); + } + } + + /// + public async Task> GetUserPermissionsAsync( + string userId, + string accessToken, + CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + var cacheKey = $"permissions:{userId}"; + if (TryGetFromCache(_permissionsCache, cacheKey, out var cached)) + { + return cached; + } + + try + { + var request = CreateRequest(HttpMethod.Get, $"/api/v1/users/{userId}/permissions", accessToken); + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to get permissions for user {UserId}. Status: {StatusCode}", userId, response.StatusCode); + return Array.Empty(); + } + + var permissionsResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + var permissions = permissionsResponse?.Data ?? Array.Empty(); + + // EN: Cache the result / VI: Cache kết quả + AddToCache(_permissionsCache, cacheKey, permissions, _settings.CacheDurationSeconds); + + return permissions; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting permissions for user {UserId}", userId); + return Array.Empty(); + } + } + + /// + public async Task HasPermissionAsync( + string userId, + string permission, + string accessToken, + CancellationToken cancellationToken = default) + { + var permissions = await GetUserPermissionsAsync(userId, accessToken, cancellationToken); + return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase); + } + + /// + public async Task HasRoleAsync( + string userId, + string role, + string accessToken, + CancellationToken cancellationToken = default) + { + var roles = await GetUserRolesAsync(userId, accessToken, cancellationToken); + return roles.Contains(role, StringComparer.OrdinalIgnoreCase); + } + + // ============================================ + // EN: Health Check / VI: Kiểm tra Health + // ============================================ + + /// + public async Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + // EN: Check cache first / VI: Kiểm tra cache trước + lock (_healthLock) + { + if (_healthCache != null && !_healthCache.IsExpired) + { + _logger.LogDebug("Health status retrieved from cache"); + return _healthCache.Value; + } + } + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, "/health"); + var response = await _httpClient.SendAsync(request, cancellationToken); + + var healthStatus = new IamHealthStatus( + response.IsSuccessStatusCode, + response.IsSuccessStatusCode ? "Healthy" : $"Unhealthy ({response.StatusCode})", + DateTime.UtcNow); + + // EN: Cache the result / VI: Cache kết quả + lock (_healthLock) + { + _healthCache = new CachedItem(healthStatus, _settings.HealthCheckCacheDurationSeconds); + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("IAM Service health check failed. Status: {StatusCode}", response.StatusCode); + } + + return healthStatus; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking IAM Service health"); + + var unhealthyStatus = new IamHealthStatus(false, $"Error: {ex.Message}", DateTime.UtcNow); + + // EN: Cache unhealthy status for shorter duration / VI: Cache trạng thái unhealthy với thời gian ngắn hơn + lock (_healthLock) + { + _healthCache = new CachedItem(unhealthyStatus, 10); // 10 seconds + } + + return unhealthyStatus; + } + } + + /// + public async Task IsAvailableAsync(CancellationToken cancellationToken = default) + { + var health = await CheckHealthAsync(cancellationToken); + return health.IsHealthy; + } + + // ============================================ + // EN: Cache Management / VI: Quản lý Cache + // ============================================ + + /// + public void InvalidateUserCache(string userId) + { + var keysToRemove = new[] + { + $"user:{userId}", + $"roles:{userId}", + $"permissions:{userId}" + }; + + foreach (var key in keysToRemove) + { + _userCache.TryRemove(key, out _); + _rolesCache.TryRemove(key, out _); + _permissionsCache.TryRemove(key, out _); + } + + _logger.LogDebug("Cache invalidated for user {UserId}", userId); + } + + /// + public void ClearCache() + { + _userCache.Clear(); + _rolesCache.Clear(); + _permissionsCache.Clear(); + + lock (_healthLock) + { + _healthCache = null; + } + + _logger.LogInformation("All IAM client caches cleared"); + } + + // ============================================ + // EN: Helper Methods / VI: Phương thức hỗ trợ + // ============================================ + + private HttpRequestMessage CreateRequest(HttpMethod method, string path, string accessToken) + { + var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.Add("X-Service-Name", _settings.ServiceName); + return request; + } + + private static bool TryGetFromCache( + ConcurrentDictionary> cache, + string key, + out T value) + { + if (cache.TryGetValue(key, out var item) && !item.IsExpired) + { + value = item.Value; + return true; + } + + value = default!; + return false; + } + + private static void AddToCache( + ConcurrentDictionary> cache, + string key, + T value, + int durationSeconds) + { + cache[key] = new CachedItem(value, durationSeconds); + } + + // ============================================ + // EN: Internal DTOs / VI: Internal DTOs + // ============================================ + + private sealed record UserMeResponse(UserData? Data); + private sealed record UserResponse(UserFullData? Data); + private sealed record RolesResponse(IReadOnlyList? Data); + private sealed record PermissionsResponse(IReadOnlyList? Data); + + private sealed record UserData( + string Id, + string Email, + string? DisplayName, + List? Roles, + List? Permissions); + + private sealed record UserFullData( + string Id, + string Email, + string? DisplayName, + bool IsActive, + List? Roles, + List? Permissions); + + /// + /// EN: Generic cached item with expiration. + /// VI: Item cache generic có thời hạn. + /// + private sealed class CachedItem + { + public T Value { get; } + private readonly DateTime _expiresAt; + + public CachedItem(T value, int durationSeconds) + { + Value = value; + _expiresAt = DateTime.UtcNow.AddSeconds(durationSeconds); + } + + public bool IsExpired => DateTime.UtcNow >= _expiresAt; + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/ExternalServices/IamServiceClient.cs b/services/membership-service-net/src/MembershipService.Infrastructure/ExternalServices/IamServiceClient.cs new file mode 100644 index 00000000..70217ffe --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/ExternalServices/IamServiceClient.cs @@ -0,0 +1,141 @@ +namespace MembershipService.Infrastructure.ExternalServices; + +/// +/// EN: Configuration for IAM Service communication. +/// VI: Cấu hình giao tiếp với IAM Service. +/// +public class IamServiceSettings +{ + public const string SectionName = "IamService"; + + /// EN: IAM Service base URL / VI: URL cơ sở của IAM Service + public string BaseUrl { get; set; } = "http://iam-service-net:8080"; + + /// EN: Internal service name header / VI: Header tên service nội bộ + public string ServiceName { get; set; } = "membership-service"; + + /// EN: Request timeout in seconds / VI: Timeout request (giây) + public int TimeoutSeconds { get; set; } = 30; + + /// EN: Cache user info duration in seconds / VI: Thời gian cache user info (giây) + public int CacheDurationSeconds { get; set; } = 300; // 5 minutes + + /// EN: Health check cache duration in seconds / VI: Thời gian cache health check (giây) + public int HealthCheckCacheDurationSeconds { get; set; } = 60; // 1 minute +} + +/// +/// EN: User information from IAM Service. +/// VI: Thông tin user từ IAM Service. +/// +public record IamUserInfo( + string UserId, + string Email, + string? DisplayName, + bool IsActive, + IReadOnlyList Roles, + IReadOnlyList? Permissions = null); + +/// +/// EN: Role information from IAM Service. +/// VI: Thông tin role từ IAM Service. +/// +public record IamRoleInfo( + string Id, + string Name, + string? Description, + IReadOnlyList Permissions); + +/// +/// EN: IAM Service health status. +/// VI: Trạng thái health của IAM Service. +/// +public record IamHealthStatus( + bool IsHealthy, + string Status, + DateTime CheckedAt); + +/// +/// EN: Interface for communicating with IAM Service. +/// VI: Interface giao tiếp với IAM Service. +/// +public interface IIamServiceClient +{ + // ============================================ + // EN: User Operations / VI: Thao tác User + // ============================================ + + /// + /// EN: Validate user token and get user info (with caching). + /// VI: Xác thực token và lấy thông tin user (có cache). + /// + Task ValidateUserAsync(string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Get user by ID. + /// VI: Lấy user theo ID. + /// + Task GetUserByIdAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user exists. + /// VI: Kiểm tra user có tồn tại. + /// + Task UserExistsAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Get user's roles. + /// VI: Lấy danh sách roles của user. + /// + Task> GetUserRolesAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Get user's permissions. + /// VI: Lấy danh sách permissions của user. + /// + Task> GetUserPermissionsAsync(string userId, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user has specific permission. + /// VI: Kiểm tra user có permission cụ thể. + /// + Task HasPermissionAsync(string userId, string permission, string accessToken, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user has specific role. + /// VI: Kiểm tra user có role cụ thể. + /// + Task HasRoleAsync(string userId, string role, string accessToken, CancellationToken cancellationToken = default); + + // ============================================ + // EN: Health Check / VI: Kiểm tra Health + // ============================================ + + /// + /// EN: Check IAM Service health (with caching). + /// VI: Kiểm tra health của IAM Service (có cache). + /// + Task CheckHealthAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Check if IAM Service is available. + /// VI: Kiểm tra IAM Service có sẵn sàng. + /// + Task IsAvailableAsync(CancellationToken cancellationToken = default); + + // ============================================ + // EN: Cache Management / VI: Quản lý Cache + // ============================================ + + /// + /// EN: Invalidate user cache. + /// VI: Xóa cache của user. + /// + void InvalidateUserCache(string userId); + + /// + /// EN: Clear all caches. + /// VI: Xóa toàn bộ cache. + /// + void ClearCache(); +}