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}")); } }