feat(membership): Introduce Membership Service with IAM integration

- Added the Membership Service configuration to the local docker-compose.yml, replacing the previous Social Service setup.
- Implemented IAM Service client with caching and health check capabilities in the Membership Service.
- Created Dependency Injection for IAM Service settings and registered the HttpClient for communication.
- Removed the outdated docker-compose.yml for the previous Social Service.
- Enhanced IAM Service client functionality to validate users, retrieve roles, and manage permissions.
This commit is contained in:
Ho Ngoc Hai
2026-01-13 23:40:59 +07:00
parent ecacde83ea
commit 3756fe6e35
5 changed files with 617 additions and 84 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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<IRequestManager, RequestManager>();
// EN: Configure IAM Service Client / VI: Cấu hình IAM Service Client
services.Configure<IamServiceSettings>(
configuration.GetSection(IamServiceSettings.SectionName));
// EN: Register HttpClient for IAM Service / VI: Đăng ký HttpClient cho IAM Service
services.AddHttpClient<IIamServiceClient, HttpIamServiceClient>((sp, client) =>
{
var settings = configuration.GetSection(IamServiceSettings.SectionName)
.Get<IamServiceSettings>() ?? 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;
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class HttpIamServiceClient : IIamServiceClient
{
private readonly HttpClient _httpClient;
private readonly IamServiceSettings _settings;
private readonly ILogger<HttpIamServiceClient> _logger;
// EN: In-memory cache for user info / VI: Cache in-memory cho user info
private readonly ConcurrentDictionary<string, CachedItem<IamUserInfo>> _userCache = new();
private readonly ConcurrentDictionary<string, CachedItem<IReadOnlyList<string>>> _rolesCache = new();
private readonly ConcurrentDictionary<string, CachedItem<IReadOnlyList<string>>> _permissionsCache = new();
// EN: Health check cache / VI: Cache health check
private CachedItem<IamHealthStatus>? _healthCache;
private readonly object _healthLock = new();
public HttpIamServiceClient(
HttpClient httpClient,
IOptions<IamServiceSettings> settings,
ILogger<HttpIamServiceClient> 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
// ============================================
/// <inheritdoc />
public async Task<IamUserInfo?> 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<UserMeResponse>(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<string>(),
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;
}
}
/// <inheritdoc />
public async Task<IamUserInfo?> 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<UserResponse>(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<string>(),
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;
}
}
/// <inheritdoc />
public async Task<bool> UserExistsAsync(
string userId,
string accessToken,
CancellationToken cancellationToken = default)
{
var user = await GetUserByIdAsync(userId, accessToken, cancellationToken);
return user != null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> 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<string>();
}
var rolesResponse = await response.Content.ReadFromJsonAsync<RolesResponse>(cancellationToken);
var roles = rolesResponse?.Data ?? Array.Empty<string>();
// 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<string>();
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> 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<string>();
}
var permissionsResponse = await response.Content.ReadFromJsonAsync<PermissionsResponse>(cancellationToken);
var permissions = permissionsResponse?.Data ?? Array.Empty<string>();
// 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<string>();
}
}
/// <inheritdoc />
public async Task<bool> HasPermissionAsync(
string userId,
string permission,
string accessToken,
CancellationToken cancellationToken = default)
{
var permissions = await GetUserPermissionsAsync(userId, accessToken, cancellationToken);
return permissions.Contains(permission, StringComparer.OrdinalIgnoreCase);
}
/// <inheritdoc />
public async Task<bool> 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
// ============================================
/// <inheritdoc />
public async Task<IamHealthStatus> 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<IamHealthStatus>(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<IamHealthStatus>(unhealthyStatus, 10); // 10 seconds
}
return unhealthyStatus;
}
}
/// <inheritdoc />
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
var health = await CheckHealthAsync(cancellationToken);
return health.IsHealthy;
}
// ============================================
// EN: Cache Management / VI: Quản lý Cache
// ============================================
/// <inheritdoc />
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);
}
/// <inheritdoc />
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<T>(
ConcurrentDictionary<string, CachedItem<T>> 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<T>(
ConcurrentDictionary<string, CachedItem<T>> cache,
string key,
T value,
int durationSeconds)
{
cache[key] = new CachedItem<T>(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<string>? Data);
private sealed record PermissionsResponse(IReadOnlyList<string>? Data);
private sealed record UserData(
string Id,
string Email,
string? DisplayName,
List<string>? Roles,
List<string>? Permissions);
private sealed record UserFullData(
string Id,
string Email,
string? DisplayName,
bool IsActive,
List<string>? Roles,
List<string>? Permissions);
/// <summary>
/// EN: Generic cached item with expiration.
/// VI: Item cache generic có thời hạn.
/// </summary>
private sealed class CachedItem<T>
{
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;
}
}

View File

@@ -0,0 +1,141 @@
namespace MembershipService.Infrastructure.ExternalServices;
/// <summary>
/// EN: Configuration for IAM Service communication.
/// VI: Cấu hình giao tiếp với IAM Service.
/// </summary>
public class IamServiceSettings
{
public const string SectionName = "IamService";
/// <summary>EN: IAM Service base URL / VI: URL cơ sở của IAM Service</summary>
public string BaseUrl { get; set; } = "http://iam-service-net:8080";
/// <summary>EN: Internal service name header / VI: Header tên service nội bộ</summary>
public string ServiceName { get; set; } = "membership-service";
/// <summary>EN: Request timeout in seconds / VI: Timeout request (giây)</summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>EN: Cache user info duration in seconds / VI: Thời gian cache user info (giây)</summary>
public int CacheDurationSeconds { get; set; } = 300; // 5 minutes
/// <summary>EN: Health check cache duration in seconds / VI: Thời gian cache health check (giây)</summary>
public int HealthCheckCacheDurationSeconds { get; set; } = 60; // 1 minute
}
/// <summary>
/// EN: User information from IAM Service.
/// VI: Thông tin user từ IAM Service.
/// </summary>
public record IamUserInfo(
string UserId,
string Email,
string? DisplayName,
bool IsActive,
IReadOnlyList<string> Roles,
IReadOnlyList<string>? Permissions = null);
/// <summary>
/// EN: Role information from IAM Service.
/// VI: Thông tin role từ IAM Service.
/// </summary>
public record IamRoleInfo(
string Id,
string Name,
string? Description,
IReadOnlyList<string> Permissions);
/// <summary>
/// EN: IAM Service health status.
/// VI: Trạng thái health của IAM Service.
/// </summary>
public record IamHealthStatus(
bool IsHealthy,
string Status,
DateTime CheckedAt);
/// <summary>
/// EN: Interface for communicating with IAM Service.
/// VI: Interface giao tiếp với IAM Service.
/// </summary>
public interface IIamServiceClient
{
// ============================================
// EN: User Operations / VI: Thao tác User
// ============================================
/// <summary>
/// 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).
/// </summary>
Task<IamUserInfo?> ValidateUserAsync(string accessToken, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get user by ID.
/// VI: Lấy user theo ID.
/// </summary>
Task<IamUserInfo?> GetUserByIdAsync(string userId, string accessToken, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if user exists.
/// VI: Kiểm tra user có tồn tại.
/// </summary>
Task<bool> UserExistsAsync(string userId, string accessToken, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get user's roles.
/// VI: Lấy danh sách roles của user.
/// </summary>
Task<IReadOnlyList<string>> GetUserRolesAsync(string userId, string accessToken, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get user's permissions.
/// VI: Lấy danh sách permissions của user.
/// </summary>
Task<IReadOnlyList<string>> GetUserPermissionsAsync(string userId, string accessToken, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if user has specific permission.
/// VI: Kiểm tra user có permission cụ thể.
/// </summary>
Task<bool> HasPermissionAsync(string userId, string permission, string accessToken, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if user has specific role.
/// VI: Kiểm tra user có role cụ thể.
/// </summary>
Task<bool> HasRoleAsync(string userId, string role, string accessToken, CancellationToken cancellationToken = default);
// ============================================
// EN: Health Check / VI: Kiểm tra Health
// ============================================
/// <summary>
/// EN: Check IAM Service health (with caching).
/// VI: Kiểm tra health của IAM Service (có cache).
/// </summary>
Task<IamHealthStatus> CheckHealthAsync(CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if IAM Service is available.
/// VI: Kiểm tra IAM Service có sẵn sàng.
/// </summary>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
// ============================================
// EN: Cache Management / VI: Quản lý Cache
// ============================================
/// <summary>
/// EN: Invalidate user cache.
/// VI: Xóa cache của user.
/// </summary>
void InvalidateUserCache(string userId);
/// <summary>
/// EN: Clear all caches.
/// VI: Xóa toàn bộ cache.
/// </summary>
void ClearCache();
}