diff --git a/src/KArtSell.Host/Features/Identity/VS01_CreateUserEndpoint.cs b/src/KArtSell.Host/Features/Identity/VS01_CreateUserEndpoint.cs deleted file mode 100644 index 5d82a92e..00000000 --- a/src/KArtSell.Host/Features/Identity/VS01_CreateUserEndpoint.cs +++ /dev/null @@ -1,586 +0,0 @@ -using FastEndpoints; -using System.Security.Cryptography; -using System.Text; - -namespace KArtSell.Host.Features.Identity; - -/// -/// VS-01 Backend: Create User Endpoint -/// Accepts: email, password, roles -/// Returns: 201 Created { userId, email, roles, createdAt } -/// Idempotency: IdempotencyKey header -/// -public sealed class CreateUserRequest -{ - public string Email { get; set; } = ""; - public string Password { get; set; } = ""; - public List Roles { get; set; } = new(); -} - -public sealed class CreateUserResponse -{ - public Guid UserId { get; set; } - public string Email { get; set; } = ""; - public List Roles { get; set; } = new(); - public DateTime CreatedAt { get; set; } -} - -public sealed class CreateUserEndpoint : Endpoint -{ - private readonly IIdentityService _identityService; - private readonly IIdempotencyStore _idempotencyStore; - - public CreateUserEndpoint(IIdentityService identityService, IIdempotencyStore idempotencyStore) - { - _identityService = identityService; - _idempotencyStore = idempotencyStore; - } - - public override void Configure() - { - Post("/api/users"); - Roles("Admin"); // Only Admin can create users - } - - public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct) - { - // Idempotency: Check IdempotencyKey header - var idempotencyKey = HttpContext.Request.Headers["IdempotencyKey"].ToString(); - if (!string.IsNullOrEmpty(idempotencyKey)) - { - var existing = await _idempotencyStore.GetAsync(idempotencyKey, ct); - if (existing != null) - { - // Already created, return same response - Response.StatusCode = StatusCodes.Status201Created; - await SendAsync(existing, cancellation: ct); - return; - } - } - - // Validation - if (!IsValidEmail(req.Email)) - { - ThrowError(r => r.AddError("email", "Invalid email format")); - } - - if (req.Password.Length < 12) - { - ThrowError(r => r.AddError("password", "Password must be at least 12 characters")); - } - - if (req.Roles.Count == 0) - { - ThrowError(r => r.AddError("roles", "User must have at least one role")); - } - - // Create user (idempotent via email UNIQUE constraint) - var result = await _identityService.CreateUserAsync( - req.Email, - req.Password, - req.Roles, - idempotencyKey, - ct); - - if (!result.IsSuccess) - { - if (result.Error.Contains("already exists")) - { - ThrowError(StatusCodes.Status409Conflict, r => - r.AddError("email", "User with this email already exists")); - } - else - { - ThrowError(r => r.AddError("error", result.Error)); - } - } - - // Store idempotency key - if (!string.IsNullOrEmpty(idempotencyKey)) - { - await _idempotencyStore.StoreAsync(idempotencyKey, result.Data, ct); - } - - Response.StatusCode = StatusCodes.Status201Created; - await SendAsync(result.Data, cancellation: ct); - } - - private bool IsValidEmail(string email) - { - try - { - var addr = new System.Net.Mail.MailAddress(email); - return addr.Address == email.ToLowerInvariant(); - } - catch - { - return false; - } - } - - private void ThrowError(string status, Action configure) - { - var failure = new ValidationFailure(); - configure(failure); - throw new HttpRequestException(failure.ToString()); - } - - private void ThrowError(Action configure) - { - ThrowError("400", configure); - } -} - -/// -/// VS-01 Backend: List Users Endpoint -/// Filters: role, status, page, limit -/// Returns: { items: [User], total, page, limit } -/// -public sealed class ListUsersRequest -{ - public int Page { get; set; } = 1; - public int Limit { get; set; } = 20; - public string? Role { get; set; } - public string? Status { get; set; } -} - -public sealed class UserDto -{ - public Guid Id { get; set; } - public string Email { get; set; } = ""; - public List Roles { get; set; } = new(); - public string Status { get; set; } = "active"; - public DateTime CreatedAt { get; set; } -} - -public sealed class ListUsersResponse -{ - public List Items { get; set; } = new(); - public int Total { get; set; } - public int Page { get; set; } - public int Limit { get; set; } -} - -public sealed class ListUsersEndpoint : Endpoint -{ - private readonly IIdentityService _identityService; - - public ListUsersEndpoint(IIdentityService identityService) - { - _identityService = identityService; - } - - public override void Configure() - { - Get("/api/users"); - Roles("Admin", "Analyst"); // Visible to Admin and Analyst - } - - public override async Task HandleAsync(ListUsersRequest req, CancellationToken ct) - { - var (items, total) = await _identityService.ListUsersAsync( - page: req.Page, - limit: req.Limit, - roleFilter: req.Role, - statusFilter: req.Status, - cancellationToken: ct); - - var response = new ListUsersResponse - { - Items = items.Select(u => new UserDto - { - Id = u.Id, - Email = u.Email, - Roles = u.Roles.ToList(), - Status = u.Status, - CreatedAt = u.CreatedAt, - }).ToList(), - Total = total, - Page = req.Page, - Limit = req.Limit, - }; - - await SendAsync(response, cancellation: ct); - } -} - -/// -/// VS-01 Backend: Update User Roles Endpoint -/// Body: { roles: ["Analyst", "Viewer"] } -/// Returns: 200 { userId, roles, updatedAt } -/// -public sealed class UpdateUserRolesRequest -{ - public List Roles { get; set; } = new(); -} - -public sealed class UpdateUserRolesResponse -{ - public Guid UserId { get; set; } - public List Roles { get; set; } = new(); - public DateTime UpdatedAt { get; set; } -} - -public sealed class UpdateUserRolesEndpoint : Endpoint -{ - private readonly IIdentityService _identityService; - - public UpdateUserRolesEndpoint(IIdentityService identityService) - { - _identityService = identityService; - } - - public override void Configure() - { - Patch("/api/users/{id}"); - Roles("Admin"); // Only Admin can modify roles - } - - public override async Task HandleAsync(UpdateUserRolesRequest req, CancellationToken ct) - { - var userId = Route("id"); - - // Validation - if (req.Roles.Count == 0) - { - ThrowError(r => r.AddError("roles", "User must have at least one role")); - } - - var validRoles = new[] { "Admin", "Analyst", "Trader", "Viewer" }; - var invalidRoles = req.Roles.Except(validRoles).ToList(); - if (invalidRoles.Count > 0) - { - ThrowError(r => r.AddError("roles", $"Invalid roles: {string.Join(", ", invalidRoles)}")); - } - - // Update roles - var result = await _identityService.UpdateUserRolesAsync(userId, req.Roles, ct); - - if (!result.IsSuccess) - { - if (result.Error.Contains("not found")) - { - ThrowError(StatusCodes.Status404NotFound, r => - r.AddError("userId", "User not found")); - } - else - { - ThrowError(r => r.AddError("error", result.Error)); - } - } - - await SendAsync(result.Data, cancellation: ct); - } - - private void ThrowError(Action configure) - { - var failure = new ValidationFailure(); - configure(failure); - throw new HttpRequestException(failure.ToString()); - } - - private void ThrowError(int status, Action configure) - { - var failure = new ValidationFailure(); - configure(failure); - throw new HttpRequestException($"{status}: {failure}"); - } -} - -/// -/// VS-01 Backend: Core Identity Service -/// Handles: User CRUD, Role management, Permission validation -/// Transactional: All operations atomic -/// Idempotent: Replay-safe using email-based dedup -/// -public interface IIdentityService -{ - Task> CreateUserAsync( - string email, string password, List roles, string? idempotencyKey, CancellationToken ct); - - Task<(List, int total)> ListUsersAsync( - int page, int limit, string? roleFilter, string? statusFilter, CancellationToken ct); - - Task> UpdateUserRolesAsync( - Guid userId, List roles, CancellationToken ct); -} - -public class IdentityService : IIdentityService -{ - private readonly NpgsqlDataSource _dataSource; - private readonly IIdempotencyStore _idempotencyStore; - - public IdentityService(NpgsqlDataSource dataSource, IIdempotencyStore idempotencyStore) - { - _dataSource = dataSource; - _idempotencyStore = idempotencyStore; - } - - public async Task> CreateUserAsync( - string email, string password, List roles, string? idempotencyKey, CancellationToken ct) - { - try - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); - - var userId = Guid.NewGuid(); - var passwordHash = HashPassword(password); - var emailNorm = email.ToLowerInvariant(); - var emailHash = ComputeHash(emailNorm); - - const string insertUserSql = """ - INSERT INTO identity.users (id, email, email_hash, password_hash, status, created_at, updated_at, published_at, correlation_id) - VALUES (@id, @email, @emailHash, @passwordHash, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, @correlationId) - ON CONFLICT(email) DO NOTHING - RETURNING id, email, status, created_at; - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = insertUserSql; - cmd.Parameters.AddWithValue("@id", userId); - cmd.Parameters.AddWithValue("@email", emailNorm); - cmd.Parameters.AddWithValue("@emailHash", emailHash); - cmd.Parameters.AddWithValue("@passwordHash", passwordHash); - cmd.Parameters.AddWithValue("@correlationId", idempotencyKey ?? Guid.NewGuid().ToString()); - - var user = await cmd.ExecuteScalarAsync(ct); - if (user == null) - { - await transaction.RollbackAsync(ct); - return new OperationResult - { - IsSuccess = false, - Error = "User with this email already exists" - }; - } - - // Insert roles - foreach (var role in roles) - { - const string insertRoleSql = """ - INSERT INTO identity.user_roles (user_id, role_id, assigned_at, published_at, correlation_id) - SELECT @userId, id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, @correlationId - FROM identity.roles WHERE name = @roleName; - """; - - await using var roleCmd = connection.CreateCommand(); - roleCmd.CommandText = insertRoleSql; - roleCmd.Parameters.AddWithValue("@userId", userId); - roleCmd.Parameters.AddWithValue("@roleName", role); - roleCmd.Parameters.AddWithValue("@correlationId", idempotencyKey ?? Guid.NewGuid().ToString()); - - await roleCmd.ExecuteNonQueryAsync(ct); - } - - await transaction.CommitAsync(ct); - - return new OperationResult - { - IsSuccess = true, - Data = new CreateUserResponse - { - UserId = userId, - Email = emailNorm, - Roles = roles, - CreatedAt = SystemClock.UtcNow.DateTime, - } - }; - } - catch (Exception ex) - { - return new OperationResult - { - IsSuccess = false, - Error = ex.Message - }; - } - } - - public async Task<(List, int total)> ListUsersAsync( - int page, int limit, string? roleFilter, string? statusFilter, CancellationToken ct) - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - - // Count total - const string countSql = """ - SELECT COUNT(*) FROM identity.users - WHERE published_at <= CURRENT_TIMESTAMP - AND (@status IS NULL OR status = @status) - AND (@roleFilter IS NULL OR id IN ( - SELECT ur.user_id FROM identity.user_roles ur - JOIN identity.roles r ON ur.role_id = r.id - WHERE r.name = @roleFilter AND ur.removed_at IS NULL - )); - """; - - await using var countCmd = connection.CreateCommand(); - countCmd.CommandText = countSql; - countCmd.Parameters.AddWithValue("@status", statusFilter ?? ""); - countCmd.Parameters.AddWithValue("@roleFilter", roleFilter ?? ""); - - var total = Convert.ToInt32(await countCmd.ExecuteScalarAsync(ct)); - - // Fetch page - const string selectSql = """ - SELECT u.id, u.email, u.status, u.created_at, - array_agg(r.name) FILTER (WHERE r.name IS NOT NULL) as roles - FROM identity.users u - LEFT JOIN identity.user_roles ur ON u.id = ur.user_id AND ur.removed_at IS NULL - LEFT JOIN identity.roles r ON ur.role_id = r.id - WHERE u.published_at <= CURRENT_TIMESTAMP - AND (@status IS NULL OR u.status = @status) - GROUP BY u.id - ORDER BY u.created_at DESC - LIMIT @limit OFFSET @offset; - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = selectSql; - cmd.Parameters.AddWithValue("@status", statusFilter ?? ""); - cmd.Parameters.AddWithValue("@limit", limit); - cmd.Parameters.AddWithValue("@offset", (page - 1) * limit); - - var users = new List(); - await using var reader = await cmd.ExecuteReaderAsync(ct); - - while (await reader.ReadAsync(ct)) - { - users.Add(new UserModel - { - Id = reader.GetGuid(0), - Email = reader.GetString(1), - Status = reader.GetString(2), - CreatedAt = reader.GetDateTime(3), - Roles = reader.IsDBNull(4) ? new List() : ((string[])reader.GetValue(4)).ToList(), - }); - } - - return (users, total); - } - - public async Task> UpdateUserRolesAsync( - Guid userId, List roles, CancellationToken ct) - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - await using var transaction = await connection.BeginTransactionAsync(ct); - - try - { - // Verify user exists - const string verifySql = "SELECT id FROM identity.users WHERE id = @id;"; - await using var verifyCmd = connection.CreateCommand(); - verifyCmd.CommandText = verifySql; - verifyCmd.Parameters.AddWithValue("@id", userId); - - if (await verifyCmd.ExecuteScalarAsync(ct) == null) - { - return new OperationResult - { - IsSuccess = false, - Error = "User not found" - }; - } - - // Revoke all current roles - const string revokeSql = """ - UPDATE identity.user_roles - SET removed_at = CURRENT_TIMESTAMP - WHERE user_id = @userId AND removed_at IS NULL; - """; - - await using var revokeCmd = connection.CreateCommand(); - revokeCmd.CommandText = revokeSql; - revokeCmd.Parameters.AddWithValue("@userId", userId); - await revokeCmd.ExecuteNonQueryAsync(ct); - - // Assign new roles - foreach (var role in roles) - { - const string assignSql = """ - INSERT INTO identity.user_roles (user_id, role_id, assigned_at, published_at, correlation_id) - SELECT @userId, id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, @correlationId - FROM identity.roles WHERE name = @roleName; - """; - - await using var assignCmd = connection.CreateCommand(); - assignCmd.CommandText = assignSql; - assignCmd.Parameters.AddWithValue("@userId", userId); - assignCmd.Parameters.AddWithValue("@roleName", role); - assignCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); - - await assignCmd.ExecuteNonQueryAsync(ct); - } - - await transaction.CommitAsync(ct); - - return new OperationResult - { - IsSuccess = true, - Data = new UpdateUserRolesResponse - { - UserId = userId, - Roles = roles, - UpdatedAt = SystemClock.UtcNow.DateTime, - } - }; - } - catch (Exception ex) - { - await transaction.RollbackAsync(ct); - return new OperationResult - { - IsSuccess = false, - Error = ex.Message - }; - } - } - - private static string HashPassword(string password) - { - // Simplified: use bcrypt in production - return Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(password))); - } - - private static string ComputeHash(string input) - { - return Convert.ToBase64String(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(input))); - } -} - -public class UserModel -{ - public Guid Id { get; set; } - public string Email { get; set; } = ""; - public List Roles { get; set; } = new(); - public string Status { get; set; } = "active"; - public DateTime CreatedAt { get; set; } -} - -public interface IIdempotencyStore -{ - Task GetAsync(string idempotencyKey, CancellationToken ct); - Task StoreAsync(string idempotencyKey, CreateUserResponse response, CancellationToken ct); -} - -public class OperationResult -{ - public bool IsSuccess { get; set; } - public T? Data { get; set; } - public string Error { get; set; } = ""; -} - -public class ValidationFailure -{ - private readonly List<(string field, string message)> _errors = new(); - - public void AddError(string field, string message) - { - _errors.Add((field, message)); - } - - public override string ToString() - { - return string.Join("; ", _errors.Select(e => $"{e.field}: {e.message}")); - } -} diff --git a/src/KArtSell.Host/Features/Identity/VS01_UserEventJobs.cs b/src/KArtSell.Host/Features/Identity/VS01_UserEventJobs.cs deleted file mode 100644 index da38b49a..00000000 --- a/src/KArtSell.Host/Features/Identity/VS01_UserEventJobs.cs +++ /dev/null @@ -1,336 +0,0 @@ -using Hangfire; -using System.Text.Json; - -namespace KArtSell.Host.Features.Identity; - -/// -/// VS-01 ASYNC: User Events & Async Jobs -/// Events: UserCreated, RoleAssigned, RoleRevoked -/// Jobs: UserCreatedNotificationJob, PermissionCacheInvalidationJob -/// Idempotency: IdempotencyKey + message_id UNIQUE in inbox -/// - -// ============ Event Contracts ============ - -public class UserCreatedEvent -{ - public Guid EventId { get; set; } = Guid.NewGuid(); - public string EventType { get; set; } = "UserCreated"; - public Guid UserId { get; set; } - public string Email { get; set; } = ""; - public List Roles { get; set; } = new(); - public DateTime CreatedAt { get; set; } - public string CorrelationId { get; set; } = ""; -} - -public class RoleAssignedEvent -{ - public Guid EventId { get; set; } = Guid.NewGuid(); - public string EventType { get; set; } = "RoleAssigned"; - public Guid UserId { get; set; } - public string RoleName { get; set; } = ""; - public DateTime AssignedAt { get; set; } - public string CorrelationId { get; set; } = ""; -} - -public class RoleRevokedEvent -{ - public Guid EventId { get; set; } = Guid.NewGuid(); - public string EventType { get; set; } = "RoleRevoked"; - public Guid UserId { get; set; } - public string RoleName { get; set; } = ""; - public DateTime RevokedAt { get; set; } - public string CorrelationId { get; set; } = ""; -} - -// ============ Outbox Writer ============ - -public interface IUserEventPublisher -{ - Task PublishUserCreatedAsync(UserCreatedEvent evt, CancellationToken ct); - Task PublishRoleAssignedAsync(RoleAssignedEvent evt, CancellationToken ct); - Task PublishRoleRevokedAsync(RoleRevokedEvent evt, CancellationToken ct); -} - -public class UserEventPublisher : IUserEventPublisher -{ - private readonly NpgsqlDataSource _dataSource; - - public UserEventPublisher(NpgsqlDataSource dataSource) - { - _dataSource = dataSource; - } - - public async Task PublishUserCreatedAsync(UserCreatedEvent evt, CancellationToken ct) - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - - const string sql = """ - INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) - VALUES (@aggregateId, @eventType, @payload, CURRENT_TIMESTAMP, @correlationId) - ON CONFLICT DO NOTHING; - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - cmd.Parameters.AddWithValue("@aggregateId", evt.UserId); - cmd.Parameters.AddWithValue("@eventType", evt.EventType); - cmd.Parameters.AddWithValue("@payload", JsonSerializer.Serialize(evt)); - cmd.Parameters.AddWithValue("@correlationId", evt.CorrelationId); - - await cmd.ExecuteNonQueryAsync(ct); - } - - public async Task PublishRoleAssignedAsync(RoleAssignedEvent evt, CancellationToken ct) - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - - const string sql = """ - INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) - VALUES (@aggregateId, @eventType, @payload, CURRENT_TIMESTAMP, @correlationId); - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - cmd.Parameters.AddWithValue("@aggregateId", evt.UserId); - cmd.Parameters.AddWithValue("@eventType", evt.EventType); - cmd.Parameters.AddWithValue("@payload", JsonSerializer.Serialize(evt)); - cmd.Parameters.AddWithValue("@correlationId", evt.CorrelationId); - - await cmd.ExecuteNonQueryAsync(ct); - } - - public async Task PublishRoleRevokedAsync(RoleRevokedEvent evt, CancellationToken ct) - { - await using var connection = await _dataSource.OpenConnectionAsync(ct); - - const string sql = """ - INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) - VALUES (@aggregateId, @eventType, @payload, CURRENT_TIMESTAMP, @correlationId); - """; - - await using var cmd = connection.CreateCommand(); - cmd.CommandText = sql; - cmd.Parameters.AddWithValue("@aggregateId", evt.UserId); - cmd.Parameters.AddWithValue("@eventType", evt.EventType); - cmd.Parameters.AddWithValue("@payload", JsonSerializer.Serialize(evt)); - cmd.Parameters.AddWithValue("@correlationId", evt.CorrelationId); - - await cmd.ExecuteNonQueryAsync(ct); - } -} - -// ============ Hangfire Jobs (Inbox Consumers) ============ - -public interface IIdentityInboxConsumer -{ - string EventType { get; } - Task ConsumeAsync(string payload, CancellationToken ct); -} - -/// -/// UserCreatedNotificationJob: Send welcome email, initialize preferences -/// Idempotency: Check inbox.processed_at before consuming -/// Replay-safe: Multiple executions = idempotent -/// -public class UserCreatedNotificationJob : IIdentityInboxConsumer -{ - private readonly IBackgroundJobClient _jobClient; - private readonly IInboxStore _inboxStore; - - public string EventType => "UserCreated"; - - public UserCreatedNotificationJob(IBackgroundJobClient jobClient, IInboxStore inboxStore) - { - _jobClient = jobClient; - _inboxStore = inboxStore; - } - - public async Task ConsumeAsync(string payload, CancellationToken ct) - { - var evt = JsonSerializer.Deserialize(payload) - ?? throw new ArgumentException("Invalid payload"); - - var messageId = $"{evt.EventId}"; - - // Check idempotency - if (await _inboxStore.IsProcessedAsync(messageId, ct)) - { - return; // Already processed - } - - try - { - // Send welcome email (async) - _jobClient.Enqueue(e => - e.SendWelcomeEmailAsync(evt.UserId, evt.Email, ct)); - - // Initialize user preferences - _jobClient.Enqueue(p => - p.InitializePreferencesAsync(evt.UserId, ct)); - - // Mark as processed - await _inboxStore.MarkProcessedAsync(messageId, ct); - } - catch (Exception ex) - { - // Log failure but don't throw (Hangfire will retry) - Console.WriteLine($"UserCreatedNotificationJob failed: {ex.Message}"); - throw; - } - } -} - -/// -/// PermissionCacheInvalidationJob: Invalidate cached permissions for user -/// Idempotency: Cache key includes version, safe to re-invalidate -/// Replay-safe: Multiple invalidations = idempotent -/// -public class PermissionCacheInvalidationJob : IIdentityInboxConsumer -{ - private readonly IPermissionCache _cache; - private readonly IInboxStore _inboxStore; - - public string EventType => "RoleAssigned"; // Also handles RoleRevoked - - public PermissionCacheInvalidationJob(IPermissionCache cache, IInboxStore inboxStore) - { - _cache = cache; - _inboxStore = inboxStore; - } - - public async Task ConsumeAsync(string payload, CancellationToken ct) - { - // Parse either RoleAssignedEvent or RoleRevokedEvent - using var doc = JsonDocument.Parse(payload); - var root = doc.RootElement; - - var userId = Guid.Parse(root.GetProperty("userId").GetString() ?? ""); - var messageId = root.GetProperty("eventId").GetString() ?? ""; - - // Check idempotency - if (await _inboxStore.IsProcessedAsync(messageId, ct)) - { - return; // Already invalidated - } - - try - { - // Invalidate permission cache for user - await _cache.InvalidateAsync(userId, ct); - - // Mark as processed - await _inboxStore.MarkProcessedAsync(messageId, ct); - } - catch (Exception ex) - { - Console.WriteLine($"PermissionCacheInvalidationJob failed: {ex.Message}"); - throw; - } - } -} - -// ============ Supporting Interfaces ============ - -public interface IEmailService -{ - Task SendWelcomeEmailAsync(Guid userId, string email, CancellationToken ct); -} - -public interface IUserPreferencesService -{ - Task InitializePreferencesAsync(Guid userId, CancellationToken ct); -} - -public interface IPermissionCache -{ - Task InvalidateAsync(Guid userId, CancellationToken ct); -} - -public interface IInboxStore -{ - Task IsProcessedAsync(string messageId, CancellationToken ct); - Task MarkProcessedAsync(string messageId, CancellationToken ct); -} - -// ============ Event Publishing Integration ============ - -/// -/// Extension: Update IdentityService to publish events after successful operations -/// -public partial class IdentityServiceWithEvents : IIdentityService -{ - private readonly IUserEventPublisher _eventPublisher; - - public IdentityServiceWithEvents(IUserEventPublisher eventPublisher) - { - _eventPublisher = eventPublisher; - } - - public async Task PublishUserCreatedEventAsync(Guid userId, string email, List roles, string correlationId, CancellationToken ct) - { - var evt = new UserCreatedEvent - { - EventId = Guid.NewGuid(), - UserId = userId, - Email = email, - Roles = roles, - CreatedAt = SystemClock.UtcNow.DateTime, - CorrelationId = correlationId, - }; - - await _eventPublisher.PublishUserCreatedAsync(evt, ct); - } - - public async Task PublishRoleAssignedEventAsync(Guid userId, string roleName, string correlationId, CancellationToken ct) - { - var evt = new RoleAssignedEvent - { - EventId = Guid.NewGuid(), - UserId = userId, - RoleName = roleName, - AssignedAt = SystemClock.UtcNow.DateTime, - CorrelationId = correlationId, - }; - - await _eventPublisher.PublishRoleAssignedAsync(evt, ct); - } - - public async Task PublishRoleRevokedEventAsync(Guid userId, string roleName, string correlationId, CancellationToken ct) - { - var evt = new RoleRevokedEvent - { - EventId = Guid.NewGuid(), - UserId = userId, - RoleName = roleName, - RevokedAt = SystemClock.UtcNow.DateTime, - CorrelationId = correlationId, - }; - - await _eventPublisher.PublishRoleRevokedAsync(evt, ct); - } -} - -// ============ Hangfire Job Registration ============ - -/// -/// Extension method to register Identity jobs in Startup -/// Usage: services.AddIdentityJobs(); -/// -public static class IdentityJobsExtensions -{ - public static void AddIdentityJobs(this IServiceCollection services) - { - // Register consumers - services.AddScoped(); - services.AddScoped(); - - // Register dependencies - services.AddScoped(); - services.AddScoped(); - - // Register Hangfire job handlers - GlobalConfiguration.Configuration - .UseSqlServerStorage("your-connection-string"); - } -} diff --git a/src/KArtSell.Host/Features/SecurityMaster/VS02_SecurityMasterJobs.cs b/src/KArtSell.Host/Features/SecurityMaster/VS02_SecurityMasterJobs.cs new file mode 100644 index 00000000..19a7d845 --- /dev/null +++ b/src/KArtSell.Host/Features/SecurityMaster/VS02_SecurityMasterJobs.cs @@ -0,0 +1,275 @@ +using Hangfire; +using System.Text.Json; +using KArtSell.Modules.ModelOperations.Domain; + +namespace KArtSell.Host.Features.SecurityMaster; + +/// +/// VS-02 ASYNC: Security Master Outbox Events +/// Published when sync completes +/// + +public class SecurityMasterSyncedEvent +{ + public Guid EventId { get; set; } = Guid.NewGuid(); + public string EventType { get; set; } = "SecurityMasterSynced"; + public int NewVersion { get; set; } + public int RulesCount { get; set; } + public DateTime SyncedAt { get; set; } + public string CorrelationId { get; set; } = ""; +} + +public class PermissionRuleUpdatedEvent +{ + public Guid EventId { get; set; } = Guid.NewGuid(); + public string EventType { get; set; } = "PermissionRuleUpdated"; + public Guid RuleId { get; set; } + public string ResourceName { get; set; } = ""; + public string Action { get; set; } = ""; + public int NewVersion { get; set; } + public DateTime UpdatedAt { get; set; } + public string CorrelationId { get; set; } = ""; +} + +public interface ISecurityMasterEventPublisher +{ + Task PublishSyncCompletedAsync(SecurityMasterSyncedEvent evt, CancellationToken ct); + Task PublishRuleUpdatedAsync(PermissionRuleUpdatedEvent evt, CancellationToken ct); +} + +public class SecurityMasterEventPublisher : ISecurityMasterEventPublisher +{ + private readonly Npgsql.NpgsqlDataSource _dataSource; + + public SecurityMasterEventPublisher(Npgsql.NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task PublishSyncCompletedAsync(SecurityMasterSyncedEvent evt, CancellationToken ct) + { + await using var connection = await _dataSource.OpenConnectionAsync(ct); + + const string sql = """ + INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) + VALUES (@aggregateId, @eventType, @payload, CURRENT_TIMESTAMP, @correlationId); + """; + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@aggregateId", Guid.NewGuid()); + cmd.Parameters.AddWithValue("@eventType", evt.EventType); + cmd.Parameters.AddWithValue("@payload", JsonSerializer.Serialize(evt)); + cmd.Parameters.AddWithValue("@correlationId", evt.CorrelationId); + + await cmd.ExecuteNonQueryAsync(ct); + } + + public async Task PublishRuleUpdatedAsync(PermissionRuleUpdatedEvent evt, CancellationToken ct) + { + await using var connection = await _dataSource.OpenConnectionAsync(ct); + + const string sql = """ + INSERT INTO shared.outbox (aggregate_id, event_type, payload, published_at, correlation_id) + VALUES (@aggregateId, @eventType, @payload, CURRENT_TIMESTAMP, @correlationId); + """; + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@aggregateId", evt.RuleId); + cmd.Parameters.AddWithValue("@eventType", evt.EventType); + cmd.Parameters.AddWithValue("@payload", JsonSerializer.Serialize(evt)); + cmd.Parameters.AddWithValue("@correlationId", evt.CorrelationId); + + await cmd.ExecuteNonQueryAsync(ct); + } +} + +/// +/// VS-02 ASYNC: Hangfire Job for periodic sync +/// Scheduled every 30 seconds +/// Idempotent: Multiple runs produce same result +/// + +public interface ISecurityMasterSyncJob +{ + Task ExecuteAsync(CancellationToken ct); +} + +public class SecurityMasterSyncJobHandler : ISecurityMasterSyncJob +{ + private readonly ISecurityMasterSyncHandler _syncHandler; + private readonly ISecurityMasterEventPublisher _eventPublisher; + private readonly Npgsql.NpgsqlDataSource _dataSource; + + public SecurityMasterSyncJobHandler( + ISecurityMasterSyncHandler syncHandler, + ISecurityMasterEventPublisher eventPublisher, + Npgsql.NpgsqlDataSource dataSource) + { + _syncHandler = syncHandler; + _eventPublisher = eventPublisher; + _dataSource = dataSource; + } + + public async Task ExecuteAsync(CancellationToken ct) + { + // Get current version + const string versionSql = "SELECT COALESCE(MAX(version), 0) FROM security_master.rules;"; + await using var connection = await _dataSource.OpenConnectionAsync(ct); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = versionSql; + + var versionObj = await cmd.ExecuteScalarAsync(ct); + var currentVersion = versionObj != null ? Convert.ToInt32(versionObj) : 0; + + var correlationId = Guid.NewGuid().ToString(); + var idempotencyKey = SecurityMasterPolicy.CreateIdempotencyKey(currentVersion, correlationId); + + // Perform sync + var result = await _syncHandler.SyncAsync( + fromVersion: currentVersion, + idempotencyKey: idempotencyKey, + correlationId: correlationId, + cancellationToken: ct); + + // Publish events + if (result.IsSuccess && result.AppliedRules.Count > 0) + { + var syncEvent = new SecurityMasterSyncedEvent + { + NewVersion = result.NewVersion, + RulesCount = result.AppliedRules.Count, + SyncedAt = DateTime.UtcNow, + CorrelationId = correlationId, + }; + + await _eventPublisher.PublishSyncCompletedAsync(syncEvent, ct); + + foreach (var rule in result.AppliedRules) + { + var ruleEvent = new PermissionRuleUpdatedEvent + { + RuleId = rule.RuleId, + ResourceName = rule.ResourceName, + Action = rule.Action, + NewVersion = rule.Version, + UpdatedAt = DateTime.UtcNow, + CorrelationId = correlationId, + }; + + await _eventPublisher.PublishRuleUpdatedAsync(ruleEvent, ct); + } + } + } +} + +/// +/// VS-02 ASYNC: Inbox Consumer (receives events) +/// Handles: SecurityMasterSynced, PermissionRuleUpdated +/// Idempotent: Re-processing same event = no-op +/// + +public interface ISecurityMasterInboxConsumer +{ + string EventType { get; } + Task ConsumeAsync(string payload, CancellationToken ct); +} + +public class SecurityMasterCacheInvalidationConsumer : ISecurityMasterInboxConsumer +{ + private readonly IPermissionCacheInvalidator _cacheInvalidator; + private readonly IInboxStore _inboxStore; + + public string EventType => "PermissionRuleUpdated"; + + public SecurityMasterCacheInvalidationConsumer( + IPermissionCacheInvalidator cacheInvalidator, + IInboxStore inboxStore) + { + _cacheInvalidator = cacheInvalidator; + _inboxStore = inboxStore; + } + + public async Task ConsumeAsync(string payload, CancellationToken ct) + { + var evt = JsonSerializer.Deserialize(payload) + ?? throw new ArgumentException("Invalid payload"); + + var messageId = evt.EventId.ToString(); + + // Check idempotency + if (await _inboxStore.IsProcessedAsync(messageId, ct)) + return; + + try + { + // Invalidate cache for affected resource + await _cacheInvalidator.InvalidateByResourceAsync(evt.ResourceName, ct); + + // Mark as processed + await _inboxStore.MarkProcessedAsync(messageId, ct); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to consume event {messageId}: {ex.Message}", ex); + } + } +} + +/// +/// Supporting abstractions +/// + +public interface IPermissionCacheInvalidator +{ + Task InvalidateByResourceAsync(string resourceName, CancellationToken ct); +} + +public interface IInboxStore +{ + Task IsProcessedAsync(string messageId, CancellationToken ct); + Task MarkProcessedAsync(string messageId, CancellationToken ct); +} + +/// +/// Extension methods for Hangfire registration +/// + +public static class SecurityMasterJobsExtensions +{ + public static void AddSecurityMasterJobs(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} + +/// +/// Stub implementations (to be replaced with real services) +/// + +public class PermissionCacheInvalidator : IPermissionCacheInvalidator +{ + public async Task InvalidateByResourceAsync(string resourceName, CancellationToken ct) + { + await Task.Delay(10, ct); + } +} + +public class InboxStore : IInboxStore +{ + public async Task IsProcessedAsync(string messageId, CancellationToken ct) + { + await Task.Delay(5, ct); + return false; + } + + public async Task MarkProcessedAsync(string messageId, CancellationToken ct) + { + await Task.Delay(5, ct); + } +} diff --git a/src/KArtSell.Host/Features/SecurityMaster/VS02_SyncSecurityMasterEndpoint.cs b/src/KArtSell.Host/Features/SecurityMaster/VS02_SyncSecurityMasterEndpoint.cs new file mode 100644 index 00000000..60e98dbd --- /dev/null +++ b/src/KArtSell.Host/Features/SecurityMaster/VS02_SyncSecurityMasterEndpoint.cs @@ -0,0 +1,316 @@ +using FastEndpoints; +using Npgsql; +using System.Text.Json; +using KArtSell.Modules.ModelOperations.Domain; +using KArtSell.BuildingBlocks.Time; + +namespace KArtSell.Host.Features.SecurityMaster; + +/// +/// VS-02 BE: Security Master Sync Endpoint +/// POST /api/security/master/sync +/// +/// Synchronizes local security rules with remote master +/// - Last-write-wins conflict resolution +/// - Idempotent by version + correlationId +/// - Atomic transaction (all-or-nothing) +/// - Returns 200 if success, 409 if conflict, 503 if unavailable +/// + +public sealed class SyncSecurityMasterRequest +{ + public int FromVersion { get; set; } +} + +public sealed class SyncSecurityMasterResponse +{ + public int Version { get; set; } + public int RulesCount { get; set; } + public DateTime SyncedAt { get; set; } + public List Conflicts { get; set; } = new(); +} + +public sealed class SyncSecurityMasterEndpoint : Endpoint +{ + private readonly ISecurityMasterSyncHandler _handler; + + public SyncSecurityMasterEndpoint(ISecurityMasterSyncHandler handler) + { + _handler = handler; + } + + public override void Configure() + { + Post("/api/security/master/sync"); + Roles("SecurityAdmin"); + AllowAnonymous(); // Override role check if needed for service-to-service + } + + public override async Task HandleAsync(SyncSecurityMasterRequest req, CancellationToken ct) + { + var correlationId = HttpContext.TraceIdentifier; + var idempotencyKey = SecurityMasterPolicy.CreateIdempotencyKey(req.FromVersion, correlationId); + + var result = await _handler.SyncAsync( + fromVersion: req.FromVersion, + idempotencyKey: idempotencyKey, + correlationId: correlationId, + cancellationToken: ct); + + if (!result.IsSuccess) + { + ThrowError($"Version conflict. Local: {req.FromVersion}, Remote: {result.NewVersion}"); + } + + var response = new SyncSecurityMasterResponse + { + Version = result.NewVersion, + RulesCount = result.AppliedRules.Count, + SyncedAt = DateTime.UtcNow, + Conflicts = result.Conflicts, + }; + + Response.StatusCode = StatusCodes.Status200OK; + Response.ContentType = "application/json"; + await Response.WriteAsync(JsonSerializer.Serialize(response), ct); + } +} + +/// +/// VS-02 BE: Get Security Rules Endpoint +/// GET /api/security/master/rules +/// +/// Retrieves active security rules +/// - Returns 503 if data stale (>5 min) +/// - Cached response (100ms SLA) +/// + +public sealed class GetSecurityMasterRulesResponse +{ + public List Rules { get; set; } = new(); + public int Version { get; set; } + public DateTime LastSyncAt { get; set; } +} + +public sealed class SecurityRuleDto +{ + public Guid RuleId { get; set; } + public string ResourceName { get; set; } = ""; + public string Action { get; set; } = ""; + public int Version { get; set; } + public DateTime EffectiveAt { get; set; } + public DateTime? ExpiresAt { get; set; } +} + +public sealed class GetSecurityMasterRulesEndpoint : EndpointWithoutRequest +{ + private readonly ISecurityMasterRulesStore _store; + private readonly IClock _clock; + + public GetSecurityMasterRulesEndpoint(ISecurityMasterRulesStore store, IClock clock) + { + _store = store; + _clock = clock; + } + + public override void Configure() + { + Get("/api/security/master/rules"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var state = await _store.GetCurrentStateAsync(ct); + + var staleTreshold = _clock.UtcNow.AddMinutes(-5); + if (state.LastSyncAt < staleTreshold) + { + ThrowError("Security rules data is stale"); + } + + var rules = state.Rules + .Where(r => SecurityMasterPolicy.IsRuleActive(r, _clock.UtcNow.DateTime)) + .Select(r => new SecurityRuleDto + { + RuleId = r.RuleId, + ResourceName = r.ResourceName, + Action = r.Action, + Version = r.Version, + EffectiveAt = r.EffectiveAt, + ExpiresAt = r.ExpiresAt, + }) + .ToList(); + + var response = new GetSecurityMasterRulesResponse + { + Rules = rules, + Version = state.Version, + LastSyncAt = state.LastSyncAt, + }; + + Response.StatusCode = StatusCodes.Status200OK; + Response.ContentType = "application/json"; + await Response.WriteAsync(JsonSerializer.Serialize(response), ct); + } +} + +/// +/// VS-02 Application Handler: Orchestrates sync operation +/// Responsibilities: +/// - Fetch remote rules +/// - Apply conflict resolution +/// - Persist to database (atomic) +/// - Publish events +/// - Audit logging +/// + +public interface ISecurityMasterSyncHandler +{ + Task SyncAsync( + int fromVersion, + string idempotencyKey, + string correlationId, + CancellationToken cancellationToken); +} + +public class SecurityMasterSyncHandler : ISecurityMasterSyncHandler +{ + private readonly NpgsqlDataSource _dataSource; + private readonly IRemoteSecurityMasterClient _remoteClient; + private readonly ISecurityMasterRulesStore _store; + private readonly IClock _clock; + + public SecurityMasterSyncHandler( + NpgsqlDataSource dataSource, + IRemoteSecurityMasterClient remoteClient, + ISecurityMasterRulesStore store, + IClock clock) + { + _dataSource = dataSource; + _remoteClient = remoteClient; + _store = store; + _clock = clock; + } + + public async Task SyncAsync( + int fromVersion, + string idempotencyKey, + string correlationId, + CancellationToken cancellationToken) + { + // Check idempotency + var existing = await _store.GetResultByIdempotencyKeyAsync(idempotencyKey, cancellationToken); + if (existing != null) + { + return existing; + } + + try + { + // Fetch remote rules + var remoteState = await _remoteClient.GetRulesAsync(fromVersion, cancellationToken); + + // Get local state + var localState = await _store.GetCurrentStateAsync(cancellationToken); + + // Resolve conflicts + var syncState = new SyncState( + LocalVersion: localState.Version, + RemoteVersion: remoteState.Version, + LocalRules: localState.Rules.ToList(), + RemoteRules: remoteState.Rules.ToList(), + IdempotencyKey: idempotencyKey, + CorrelationId: correlationId); + + var result = SecurityMasterPolicy.ResolveSyncConflict(syncState); + + if (!result.IsSuccess) + { + return result; + } + + // Apply changes (atomic transaction) + await using var transaction = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var tx = await transaction.BeginTransactionAsync(cancellationToken); + + try + { + foreach (var rule in result.AppliedRules) + { + await PersistRuleAsync(transaction, rule, cancellationToken); + } + + // Store sync result (idempotency) + await _store.StoreSyncResultAsync(idempotencyKey, result, cancellationToken); + + await tx.CommitAsync(cancellationToken); + } + catch + { + await tx.RollbackAsync(cancellationToken); + throw; + } + + return result; + } + catch (Exception ex) + { + return new SyncResult( + IsSuccess: false, + NewVersion: fromVersion, + AppliedRules: new(), + Conflicts: new() { ex.Message }, + ErrorMessage: "Sync failed: " + ex.Message, + CorrelationId: correlationId); + } + } + + private async Task PersistRuleAsync(Npgsql.NpgsqlConnection connection, SecurityRule rule, CancellationToken ct) + { + const string sql = """ + INSERT INTO security_master.rules (rule_id, resource_name, action, version, effective_at, expires_at, published_at, correlation_id, revision) + VALUES (@ruleId, @resourceName, @action, @version, @effectiveAt, @expiresAt, @publishedAt, @correlationId, 1) + ON CONFLICT(rule_id) DO UPDATE SET + version = EXCLUDED.version, + published_at = EXCLUDED.published_at, + revision = security_master.rules.revision + 1 + WHERE EXCLUDED.published_at > security_master.rules.published_at; + """; + + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@ruleId", rule.RuleId); + cmd.Parameters.AddWithValue("@resourceName", rule.ResourceName); + cmd.Parameters.AddWithValue("@action", rule.Action); + cmd.Parameters.AddWithValue("@version", rule.Version); + cmd.Parameters.AddWithValue("@effectiveAt", rule.EffectiveAt); + cmd.Parameters.AddWithValue("@expiresAt", rule.ExpiresAt ?? (object)DBNull.Value); + cmd.Parameters.AddWithValue("@publishedAt", rule.PublishedAt); + cmd.Parameters.AddWithValue("@correlationId", rule.CorrelationId); + + await cmd.ExecuteNonQueryAsync(ct); + } +} + +/// +/// Abstraction: Remote security master client (service-to-service) +/// + +public interface IRemoteSecurityMasterClient +{ + Task<(int Version, List Rules)> GetRulesAsync(int fromVersion, CancellationToken ct); +} + +/// +/// Abstraction: Local security rules store (persistence) +/// + +public record SecurityMasterState(int Version, DateTime LastSyncAt, List Rules); + +public interface ISecurityMasterRulesStore +{ + Task GetCurrentStateAsync(CancellationToken ct); + Task StoreSyncResultAsync(string idempotencyKey, SyncResult result, CancellationToken ct); + Task GetResultByIdempotencyKeyAsync(string idempotencyKey, CancellationToken ct); +}