diff --git a/.gitignore b/.gitignore index ce2b5ee..05cd65c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # dotenv files .env appsettings.Local.json +appsettings*.json +line-gestao-api.csproj +line-gestao-api.http # User-specific files *.rsuser diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index fe2251d..8d7ac24 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -36,7 +36,7 @@ public class AuthController : ControllerBase } [HttpPost("register")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Register(RegisterRequest req) { if (req.Password != req.ConfirmPassword) @@ -69,7 +69,7 @@ public class AuthController : ControllerBase if (!createResult.Succeeded) return BadRequest(createResult.Errors.Select(e => e.Description).ToList()); - await _userManager.AddToRoleAsync(user, "leitura"); + await _userManager.AddToRoleAsync(user, AppRoles.Cliente); var effectiveTenantId = await EnsureValidTenantIdAsync(user); if (!effectiveTenantId.HasValue) @@ -192,22 +192,19 @@ public class AuthController : ControllerBase private async Task EnsureValidTenantIdAsync(ApplicationUser user) { - if (user.TenantId != Guid.Empty) - return user.TenantId; + if (user.TenantId == Guid.Empty) + { + return null; + } - var fallbackTenantId = await _db.Tenants + var existsAndActive = await _db.Tenants .AsNoTracking() - .OrderBy(t => t.CreatedAt) - .Select(t => (Guid?)t.Id) - .FirstOrDefaultAsync(); + .AnyAsync(t => t.Id == user.TenantId && t.Ativo); - if (!fallbackTenantId.HasValue || fallbackTenantId.Value == Guid.Empty) - return null; - - user.TenantId = fallbackTenantId.Value; - var updateResult = await _userManager.UpdateAsync(user); - if (!updateResult.Succeeded) + if (!existsAndActive) + { return null; + } return user.TenantId; } diff --git a/Controllers/BillingController.cs b/Controllers/BillingController.cs index 55d8aac..82e3077 100644 --- a/Controllers/BillingController.cs +++ b/Controllers/BillingController.cs @@ -10,7 +10,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/[controller]")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public class BillingController : ControllerBase { private readonly AppDbContext _db; @@ -197,7 +197,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateBillingClientRequest req) { var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); @@ -230,7 +230,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/ChipsVirgensController.cs b/Controllers/ChipsVirgensController.cs index 62b88ac..4c1a3f1 100644 --- a/Controllers/ChipsVirgensController.cs +++ b/Controllers/ChipsVirgensController.cs @@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/chips-virgens")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public class ChipsVirgensController : ControllerBase { private readonly AppDbContext _db; @@ -93,7 +93,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateChipVirgemDto req) { var now = DateTime.UtcNow; @@ -122,7 +122,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateChipVirgemRequest req) { var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id); @@ -139,7 +139,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/ControleRecebidosController.cs b/Controllers/ControleRecebidosController.cs index 918a73b..1457b72 100644 --- a/Controllers/ControleRecebidosController.cs +++ b/Controllers/ControleRecebidosController.cs @@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/controle-recebidos")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public class ControleRecebidosController : ControllerBase { private readonly AppDbContext _db; @@ -138,7 +138,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateControleRecebidoDto req) { var now = DateTime.UtcNow; @@ -195,7 +195,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateControleRecebidoRequest req) { var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id); @@ -223,7 +223,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/HistoricoController.cs b/Controllers/HistoricoController.cs index 4e37612..8ec234b 100644 --- a/Controllers/HistoricoController.cs +++ b/Controllers/HistoricoController.cs @@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers; [ApiController] [Route("api/historico")] -[Authorize(Roles = "admin")] +[Authorize(Roles = "sysadmin,gestor")] public class HistoricoController : ControllerBase { private readonly AppDbContext _db; diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index fc3c89a..7418142 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -105,6 +105,9 @@ namespace line_gestao_api.Controllers query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } + if (!reservaFilter) + query = ExcludeReservaContext(query); + query = ApplyAdditionalFilters(query, additionalMode, additionalServices); IQueryable groupedQuery; @@ -232,6 +235,9 @@ namespace line_gestao_api.Controllers query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } + if (!reservaFilter) + query = ExcludeReservaContext(query); + query = ApplyAdditionalFilters(query, additionalMode, additionalServices); List clients; @@ -468,6 +474,9 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); } + if (!reservaFilter) + q = ExcludeReservaContext(q); + q = ApplyAdditionalFilters(q, additionalMode, additionalServices); var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); @@ -686,7 +695,7 @@ namespace line_gestao_api.Controllers // ✅ 5. CREATE // ========================================================== [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateMobileLineDto req) { if (string.IsNullOrWhiteSpace(req.Cliente)) @@ -796,7 +805,7 @@ namespace line_gestao_api.Controllers // ✅ 5.1. CREATE BATCH // ========================================================== [HttpPost("batch")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> CreateBatch([FromBody] CreateMobileLinesBatchRequestDto req) { var requests = req?.Lines ?? new List(); @@ -976,7 +985,7 @@ namespace line_gestao_api.Controllers // ✅ 5.2. PREVIEW IMPORTAÇÃO EXCEL PARA LOTE (ABA GERAL) // ========================================================== [HttpPost("batch/import-preview")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] [Consumes("multipart/form-data")] [RequestSizeLimit(20_000_000)] public async Task> PreviewBatchImportExcel([FromForm] ImportExcelForm form) @@ -1006,7 +1015,7 @@ namespace line_gestao_api.Controllers // ✅ 5.3. ATRIBUIR LINHAS DA RESERVA PARA CLIENTE // ========================================================== [HttpPost("reserva/assign-client")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> AssignReservaLinesToClient([FromBody] AssignReservaLinesRequestDto req) { var ids = (req?.LineIds ?? new List()) @@ -1146,7 +1155,7 @@ namespace line_gestao_api.Controllers // ✅ 5.4. MOVER LINHAS DE CLIENTE PARA RESERVA // ========================================================== [HttpPost("move-to-reserva")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> MoveLinesToReserva([FromBody] MoveLinesToReservaRequestDto req) { var ids = (req?.LineIds ?? new List()) @@ -1251,7 +1260,7 @@ namespace line_gestao_api.Controllers // ✅ 6. UPDATE // ========================================================== [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateMobileLineRequest req) { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); @@ -1345,7 +1354,7 @@ namespace line_gestao_api.Controllers // ✅ 7. DELETE // ========================================================== [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); @@ -1360,7 +1369,7 @@ namespace line_gestao_api.Controllers // ✅ 8. IMPORT EXCEL // ========================================================== [HttpPost("import-excel")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] [Consumes("multipart/form-data")] [RequestSizeLimit(50_000_000)] public async Task> ImportExcel([FromForm] ImportExcelForm form) @@ -4336,6 +4345,14 @@ namespace line_gestao_api.Controllers }); } + private static IQueryable ExcludeReservaContext(IQueryable query) + { + return query.Where(x => + !EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") && + !EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") && + !EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); + } + private static IQueryable ApplyAdditionalFilters( IQueryable query, string? additionalMode, diff --git a/Controllers/MuregController.cs b/Controllers/MuregController.cs index 1ebd16b..4ec5909 100644 --- a/Controllers/MuregController.cs +++ b/Controllers/MuregController.cs @@ -187,7 +187,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateMuregDto req) { if (req.MobileLineId == Guid.Empty) @@ -289,7 +289,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateMuregDto req) { var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id); @@ -361,7 +361,7 @@ namespace line_gestao_api.Controllers // Exclui registro MUREG // ========================================================== [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id); @@ -377,7 +377,7 @@ namespace line_gestao_api.Controllers // ✅ POST: /api/mureg/import-excel (mantido) // ========================================================== [HttpPost("import-excel")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] [Consumes("multipart/form-data")] [RequestSizeLimit(50_000_000)] public async Task ImportExcel([FromForm] ImportExcelForm form) diff --git a/Controllers/ParcelamentosController.cs b/Controllers/ParcelamentosController.cs index 6d749f3..71b9f75 100644 --- a/Controllers/ParcelamentosController.cs +++ b/Controllers/ParcelamentosController.cs @@ -9,7 +9,7 @@ namespace line_gestao_api.Controllers; [ApiController] [Route("api/parcelamentos")] -[Authorize(Roles = "admin")] +[Authorize(Roles = "sysadmin,gestor")] public class ParcelamentosController : ControllerBase { private readonly AppDbContext _db; @@ -165,7 +165,7 @@ public class ParcelamentosController : ControllerBase } [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] ParcelamentoUpsertDto req) { var now = DateTime.UtcNow; @@ -202,7 +202,7 @@ public class ParcelamentosController : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] ParcelamentoUpsertDto req) { var entity = await _db.ParcelamentoLines @@ -239,7 +239,7 @@ public class ParcelamentosController : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Delete(Guid id) { var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id); diff --git a/Controllers/SystemTenantUsersController.cs b/Controllers/SystemTenantUsersController.cs new file mode 100644 index 0000000..6e176a5 --- /dev/null +++ b/Controllers/SystemTenantUsersController.cs @@ -0,0 +1,226 @@ +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using line_gestao_api.Models; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/system/tenants/{tenantId:guid}/users")] +[Authorize(Policy = "SystemAdmin")] +public class SystemTenantUsersController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly UserManager _userManager; + private readonly RoleManager> _roleManager; + private readonly ISystemAuditService _systemAuditService; + + public SystemTenantUsersController( + AppDbContext db, + UserManager userManager, + RoleManager> roleManager, + ISystemAuditService systemAuditService) + { + _db = db; + _userManager = userManager; + _roleManager = roleManager; + _systemAuditService = systemAuditService; + } + + [HttpPost] + public async Task> CreateUserForTenant( + [FromRoute] Guid tenantId, + [FromBody] CreateSystemTenantUserRequest request) + { + if (tenantId == Guid.Empty) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "TenantId inválido.", "invalid_tenant_id"); + } + + var tenant = await _db.Tenants.AsNoTracking().FirstOrDefaultAsync(t => t.Id == tenantId); + if (tenant == null) + { + return await RejectAsync(tenantId, StatusCodes.Status404NotFound, "Tenant não encontrado.", "tenant_not_found"); + } + + if (string.IsNullOrWhiteSpace(request.Email)) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Email é obrigatório.", "missing_email"); + } + + if (string.IsNullOrWhiteSpace(request.Password)) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Password é obrigatória.", "missing_password"); + } + + if (request.Roles == null || request.Roles.Count == 0) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Informe ao menos uma role.", "missing_roles"); + } + + var normalizedRoles = request.Roles + .Where(r => !string.IsNullOrWhiteSpace(r)) + .Select(r => r.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (normalizedRoles.Count == 0) + { + return await RejectAsync(tenantId, StatusCodes.Status400BadRequest, "Roles inválidas.", "invalid_roles"); + } + + var unsupportedRoles = normalizedRoles + .Where(role => !AppRoles.All.Contains(role, StringComparer.OrdinalIgnoreCase)) + .ToList(); + if (unsupportedRoles.Count > 0) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + $"Roles não suportadas: {string.Join(", ", unsupportedRoles)}. Use apenas: {string.Join(", ", AppRoles.All)}.", + "unsupported_roles"); + } + + if (!tenant.IsSystem && normalizedRoles.Contains(SystemTenantConstants.SystemRole)) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + "A role sysadmin só pode ser usada no SystemTenant.", + "invalid_sysadmin_outside_system_tenant"); + } + + if (tenant.IsSystem && normalizedRoles.Any(r => r != SystemTenantConstants.SystemRole)) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + "No SystemTenant é permitido apenas a role sysadmin.", + "invalid_non_system_role_for_system_tenant"); + } + + foreach (var role in normalizedRoles) + { + if (!await _roleManager.RoleExistsAsync(role)) + { + return await RejectAsync( + tenantId, + StatusCodes.Status400BadRequest, + $"Role inexistente: {role}", + "role_not_found"); + } + } + + var email = request.Email.Trim().ToLowerInvariant(); + var normalizedEmail = _userManager.NormalizeEmail(email); + + var alreadyExists = await _userManager.Users + .IgnoreQueryFilters() + .AnyAsync(u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail); + + if (alreadyExists) + { + return await RejectAsync(tenantId, StatusCodes.Status409Conflict, "Já existe usuário com este email neste tenant.", "email_exists"); + } + + var name = string.IsNullOrWhiteSpace(request.Name) + ? email + : request.Name.Trim(); + + var user = new ApplicationUser + { + Name = name, + Email = email, + UserName = email, + TenantId = tenantId, + EmailConfirmed = true, + IsActive = true, + LockoutEnabled = true + }; + + IdentityResult createResult; + try + { + createResult = await _userManager.CreateAsync(user, request.Password); + } + catch (DbUpdateException) + { + return await RejectAsync( + tenantId, + StatusCodes.Status409Conflict, + "Não foi possível criar usuário. Email/username já em uso.", + "db_conflict"); + } + + if (!createResult.Succeeded) + { + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUserRejected, + targetTenantId: tenantId, + metadata: new + { + reason = "identity_create_failed", + email, + errors = createResult.Errors.Select(e => e.Description).ToList() + }); + + return BadRequest(createResult.Errors.Select(e => e.Description).ToList()); + } + + var addRolesResult = await _userManager.AddToRolesAsync(user, normalizedRoles); + if (!addRolesResult.Succeeded) + { + await _userManager.DeleteAsync(user); + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUserRejected, + targetTenantId: tenantId, + metadata: new + { + reason = "identity_add_roles_failed", + email, + roles = normalizedRoles, + errors = addRolesResult.Errors.Select(e => e.Description).ToList() + }); + + return BadRequest(addRolesResult.Errors.Select(e => e.Description).ToList()); + } + + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUser, + targetTenantId: tenantId, + metadata: new + { + createdUserId = user.Id, + email, + roles = normalizedRoles + }); + + var response = new SystemTenantUserCreatedDto + { + UserId = user.Id, + TenantId = tenantId, + Email = email, + Roles = normalizedRoles + }; + + return StatusCode(StatusCodes.Status201Created, response); + } + + private async Task> RejectAsync( + Guid targetTenantId, + int statusCode, + string message, + string reason) + { + await _systemAuditService.LogAsync( + action: SystemAuditActions.CreateTenantUserRejected, + targetTenantId: targetTenantId, + metadata: new { reason, message }); + + return StatusCode(statusCode, message); + } +} diff --git a/Controllers/SystemTenantsController.cs b/Controllers/SystemTenantsController.cs new file mode 100644 index 0000000..5d38b61 --- /dev/null +++ b/Controllers/SystemTenantsController.cs @@ -0,0 +1,56 @@ +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace line_gestao_api.Controllers; + +[ApiController] +[Route("api/system/tenants")] +[Authorize(Policy = "SystemAdmin")] +public class SystemTenantsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ISystemAuditService _systemAuditService; + + public SystemTenantsController(AppDbContext db, ISystemAuditService systemAuditService) + { + _db = db; + _systemAuditService = systemAuditService; + } + + [HttpGet] + public async Task>> GetTenants( + [FromQuery] string source = SystemTenantConstants.MobileLinesClienteSourceType, + [FromQuery] bool active = true) + { + var query = _db.Tenants + .AsNoTracking() + .Where(t => !t.IsSystem); + + if (!string.IsNullOrWhiteSpace(source)) + { + query = query.Where(t => t.SourceType == source); + } + + query = query.Where(t => t.Ativo == active); + + var tenants = await query + .OrderBy(t => t.NomeOficial) + .Select(t => new SystemTenantListItemDto + { + TenantId = t.Id, + NomeOficial = t.NomeOficial + }) + .ToListAsync(); + + await _systemAuditService.LogAsync( + action: SystemAuditActions.ListTenants, + targetTenantId: SystemTenantConstants.SystemTenantId, + metadata: new { source, active, returnedCount = tenants.Count }); + + return Ok(tenants); + } +} diff --git a/Controllers/TemplatesController.cs b/Controllers/TemplatesController.cs index 98a7e6a..adb254a 100644 --- a/Controllers/TemplatesController.cs +++ b/Controllers/TemplatesController.cs @@ -6,7 +6,7 @@ namespace line_gestao_api.Controllers { [ApiController] [Route("api/templates")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public class TemplatesController : ControllerBase { private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService; diff --git a/Controllers/TrocaNumeroController.cs b/Controllers/TrocaNumeroController.cs index 759764a..50111f0 100644 --- a/Controllers/TrocaNumeroController.cs +++ b/Controllers/TrocaNumeroController.cs @@ -112,7 +112,7 @@ namespace line_gestao_api.Controllers // ✅ CREATE // ========================================================== [HttpPost] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task> Create([FromBody] CreateTrocaNumeroDto req) { var now = DateTime.UtcNow; @@ -141,7 +141,7 @@ namespace line_gestao_api.Controllers // ✅ UPDATE // ========================================================== [HttpPut("{id:guid}")] - [Authorize(Roles = "admin,gestor")] + [Authorize(Roles = "sysadmin,gestor")] public async Task Update(Guid id, [FromBody] UpdateTrocaNumeroRequest req) { var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); @@ -167,7 +167,7 @@ namespace line_gestao_api.Controllers // ✅ DELETE // ========================================================== [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/UserDataController.cs b/Controllers/UserDataController.cs index 58dbfc8..4fbaf19 100644 --- a/Controllers/UserDataController.cs +++ b/Controllers/UserDataController.cs @@ -263,7 +263,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> Create([FromBody] CreateUserDataRequest req) { var now = DateTime.UtcNow; @@ -365,7 +365,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Update(Guid id, [FromBody] UpdateUserDataRequest req) { var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id); @@ -397,7 +397,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs index 17f4ef7..e37e6b1 100644 --- a/Controllers/UsersController.cs +++ b/Controllers/UsersController.cs @@ -17,8 +17,9 @@ public class UsersController : ControllerBase { private static readonly HashSet AllowedRoles = new(StringComparer.OrdinalIgnoreCase) { - "admin", - "gestor" + AppRoles.SysAdmin, + AppRoles.Gestor, + AppRoles.Cliente }; private readonly AppDbContext _db; @@ -39,7 +40,7 @@ public class UsersController : ControllerBase } [HttpPost] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> Create([FromBody] UserCreateRequest req) { var errors = ValidateCreate(req); @@ -122,7 +123,7 @@ public class UsersController : ControllerBase } [HttpGet] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task>> GetAll( [FromQuery] string? search, [FromQuery] string? permissao, @@ -191,7 +192,7 @@ public class UsersController : ControllerBase } [HttpGet("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> GetById(Guid id) { var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id); @@ -215,7 +216,7 @@ public class UsersController : ControllerBase } [HttpPatch("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Update(Guid id, [FromBody] UserUpdateRequest req) { var errors = await ValidateUpdateAsync(id, req); @@ -295,7 +296,7 @@ public class UsersController : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { if (_tenantProvider.TenantId == null) @@ -334,12 +335,12 @@ public class UsersController : ControllerBase } var targetRoles = await _userManager.GetRolesAsync(user); - var isAdmin = targetRoles.Any(r => string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase)); + var isAdmin = targetRoles.Any(r => string.Equals(r, AppRoles.SysAdmin, StringComparison.OrdinalIgnoreCase)); if (isAdmin) { var adminRoleId = await _roleManager.Roles - .Where(r => r.Name == "admin") + .Where(r => r.Name == AppRoles.SysAdmin) .Select(r => (Guid?)r.Id) .FirstOrDefaultAsync(); @@ -360,7 +361,7 @@ public class UsersController : ControllerBase { Errors = new List { - new() { Field = "usuario", Message = "Não é permitido excluir o último administrador." } + new() { Field = "usuario", Message = "Não é permitido excluir o último sysadmin." } } }); } diff --git a/Controllers/VigenciaController.cs b/Controllers/VigenciaController.cs index 4b700e8..5dcf027 100644 --- a/Controllers/VigenciaController.cs +++ b/Controllers/VigenciaController.cs @@ -256,7 +256,7 @@ namespace line_gestao_api.Controllers } [HttpPost] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task> Create([FromBody] CreateVigenciaRequest req) { var now = DateTime.UtcNow; @@ -354,7 +354,7 @@ namespace line_gestao_api.Controllers } [HttpPut("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Update(Guid id, [FromBody] UpdateVigenciaRequest req) { var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); @@ -423,7 +423,7 @@ namespace line_gestao_api.Controllers } [HttpDelete("{id:guid}")] - [Authorize(Roles = "admin")] + [Authorize(Roles = "sysadmin")] public async Task Delete(Guid id) { var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0c8f76a..c086ae5 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -78,6 +78,15 @@ public class AppDbContext : IdentityDbContext(e => + { + e.Property(x => x.NomeOficial).HasMaxLength(200); + e.Property(x => x.SourceType).HasMaxLength(80); + e.Property(x => x.SourceKey).HasMaxLength(300); + e.HasIndex(x => new { x.SourceType, x.SourceKey }).IsUnique(); + e.HasIndex(x => new { x.IsSystem, x.Ativo }); + }); + // ========================= // ✅ USER (Identity) // ========================= @@ -271,6 +280,7 @@ public class AppDbContext : IdentityDbContext(e => { + e.Property(x => x.MetadataJson).HasColumnType("jsonb"); e.Property(x => x.Action).HasMaxLength(20); e.Property(x => x.Page).HasMaxLength(80); e.Property(x => x.EntityName).HasMaxLength(120); @@ -282,8 +292,13 @@ public class AppDbContext : IdentityDbContext x.RequestMethod).HasMaxLength(10); e.Property(x => x.IpAddress).HasMaxLength(80); e.Property(x => x.ChangesJson).HasColumnType("jsonb"); + e.Property(x => x.ActorTenantId); + e.Property(x => x.TargetTenantId); e.HasIndex(x => x.TenantId); + e.HasIndex(x => x.ActorTenantId); + e.HasIndex(x => x.TargetTenantId); + e.HasIndex(x => x.ActorUserId); e.HasIndex(x => x.OccurredAtUtc); e.HasIndex(x => x.Page); e.HasIndex(x => x.UserId); @@ -319,33 +334,33 @@ public class AppDbContext : IdentityDbContext().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); - modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); + modelBuilder.Entity().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId)); } public override int SaveChanges() @@ -364,12 +379,12 @@ public class AppDbContext : IdentityDbContext().Where(e => e.State == EntityState.Added)) { if (entry.Entity.TenantId == Guid.Empty) diff --git a/Data/SeedData.cs b/Data/SeedData.cs index 52a9132..12b5858 100644 --- a/Data/SeedData.cs +++ b/Data/SeedData.cs @@ -9,17 +9,14 @@ namespace line_gestao_api.Data; public class SeedOptions { public bool Enabled { get; set; } = true; - public string DefaultTenantName { get; set; } = "Default"; - public string AdminName { get; set; } = "Administrador"; - public string AdminEmail { get; set; } = "admin@linegestao.local"; - public string AdminPassword { get; set; } = "DevAdmin123!"; + public string AdminMasterName { get; set; } = "System Admin"; + public string? AdminMasterEmail { get; set; } = "sysadmin@linegestao.local"; + public string? AdminMasterPassword { get; set; } = "DevSysAdmin123!"; public bool ReapplyAdminCredentialsOnStartup { get; set; } = false; } public static class SeedData { - public static readonly Guid DefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111"); - public static async Task EnsureSeedDataAsync(IServiceProvider services) { using var scope = services.CreateScope(); @@ -29,224 +26,236 @@ public static class SeedData var tenantProvider = scope.ServiceProvider.GetRequiredService(); var options = scope.ServiceProvider.GetRequiredService>().Value; - await db.Database.MigrateAsync(); + if (db.Database.IsRelational()) + { + await db.Database.MigrateAsync(); + } + else + { + await db.Database.EnsureCreatedAsync(); + } if (!options.Enabled) { return; } - var roles = new[] { "admin", "gestor", "operador", "leitura" }; + var systemTenantId = SystemTenantConstants.SystemTenantId; + var roles = AppRoles.All; foreach (var role in roles) { if (!await roleManager.RoleExistsAsync(role)) { - await roleManager.CreateAsync(new IdentityRole(role)); + var roleResult = await roleManager.CreateAsync(new IdentityRole(role)); + EnsureIdentitySucceeded(roleResult, $"Falha ao criar role '{role}'."); } } - var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == DefaultTenantId); - if (tenant == null) + await MigrateLegacyRolesAsync(db, roleManager); + + var systemTenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == systemTenantId); + if (systemTenant == null) { - tenant = new Tenant + systemTenant = new Tenant { - Id = DefaultTenantId, - Name = options.DefaultTenantName, + Id = systemTenantId, + NomeOficial = SystemTenantConstants.SystemTenantNomeOficial, + IsSystem = true, + Ativo = true, CreatedAt = DateTime.UtcNow }; - - db.Tenants.Add(tenant); - await db.SaveChangesAsync(); + db.Tenants.Add(systemTenant); + } + else + { + systemTenant.NomeOficial = SystemTenantConstants.SystemTenantNomeOficial; + systemTenant.IsSystem = true; + systemTenant.Ativo = true; } - await NormalizeLegacyTenantDataAsync(db, tenant.Id); + await db.SaveChangesAsync(); - tenantProvider.SetTenantId(tenant.Id); + var emailFromEnv = Environment.GetEnvironmentVariable("SYSADMIN_EMAIL") + ?? Environment.GetEnvironmentVariable("ADMIN_MASTER_EMAIL"); + var passwordFromEnv = Environment.GetEnvironmentVariable("SYSADMIN_PASSWORD") + ?? Environment.GetEnvironmentVariable("ADMIN_MASTER_PASSWORD"); - var normalizedEmail = userManager.NormalizeEmail(options.AdminEmail); - var existingAdmin = await userManager.Users - .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenant.Id); + var adminMasterEmail = (emailFromEnv ?? options.AdminMasterEmail ?? string.Empty).Trim().ToLowerInvariant(); + var adminMasterPassword = passwordFromEnv ?? options.AdminMasterPassword ?? string.Empty; - if (existingAdmin == null) + if (string.IsNullOrWhiteSpace(adminMasterEmail) || string.IsNullOrWhiteSpace(adminMasterPassword)) { - var adminUser = new ApplicationUser - { - UserName = options.AdminEmail, - Email = options.AdminEmail, - Name = options.AdminName, - TenantId = tenant.Id, - EmailConfirmed = true, - IsActive = true, - LockoutEnabled = true - }; - - var createResult = await userManager.CreateAsync(adminUser, options.AdminPassword); - if (createResult.Succeeded) - { - await userManager.AddToRoleAsync(adminUser, "admin"); - } + throw new InvalidOperationException( + "Credenciais do sysadmin ausentes. Defina SYSADMIN_EMAIL e SYSADMIN_PASSWORD (ou Seed:AdminMasterEmail/Seed:AdminMasterPassword)."); } - else if (options.ReapplyAdminCredentialsOnStartup) + + var normalizedEmail = userManager.NormalizeEmail(adminMasterEmail); + + var previousTenant = tenantProvider.TenantId; + tenantProvider.SetTenantId(systemTenantId); + + try { - existingAdmin.Name = options.AdminName; - existingAdmin.Email = options.AdminEmail; - existingAdmin.UserName = options.AdminEmail; - existingAdmin.EmailConfirmed = true; - existingAdmin.IsActive = true; - existingAdmin.LockoutEnabled = true; + var existingAdminMaster = await userManager.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.TenantId == systemTenantId && u.NormalizedEmail == normalizedEmail); - await userManager.SetLockoutEndDateAsync(existingAdmin, null); - await userManager.ResetAccessFailedCountAsync(existingAdmin); - await userManager.UpdateAsync(existingAdmin); - - var resetToken = await userManager.GeneratePasswordResetTokenAsync(existingAdmin); - var resetPasswordResult = await userManager.ResetPasswordAsync(existingAdmin, resetToken, options.AdminPassword); - if (!resetPasswordResult.Succeeded) + if (existingAdminMaster == null) { - var removePasswordResult = await userManager.RemovePasswordAsync(existingAdmin); - if (removePasswordResult.Succeeded) + var adminMaster = new ApplicationUser { - await userManager.AddPasswordAsync(existingAdmin, options.AdminPassword); + Name = options.AdminMasterName, + Email = adminMasterEmail, + UserName = adminMasterEmail, + TenantId = systemTenantId, + EmailConfirmed = true, + IsActive = true, + LockoutEnabled = true + }; + + var createResult = await userManager.CreateAsync(adminMaster, adminMasterPassword); + EnsureIdentitySucceeded(createResult, "Falha ao criar usuário sysadmin."); + + var addRoleResult = await userManager.AddToRoleAsync(adminMaster, SystemTenantConstants.SystemRole); + EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial."); + } + else + { + existingAdminMaster.Name = options.AdminMasterName; + existingAdminMaster.Email = adminMasterEmail; + existingAdminMaster.UserName = adminMasterEmail; + existingAdminMaster.EmailConfirmed = true; + existingAdminMaster.IsActive = true; + existingAdminMaster.LockoutEnabled = true; + + var updateResult = await userManager.UpdateAsync(existingAdminMaster); + EnsureIdentitySucceeded(updateResult, "Falha ao atualizar usuário sysadmin."); + + if (options.ReapplyAdminCredentialsOnStartup) + { + await userManager.SetLockoutEndDateAsync(existingAdminMaster, null); + await userManager.ResetAccessFailedCountAsync(existingAdminMaster); + + var resetToken = await userManager.GeneratePasswordResetTokenAsync(existingAdminMaster); + var resetPasswordResult = await userManager.ResetPasswordAsync(existingAdminMaster, resetToken, adminMasterPassword); + if (!resetPasswordResult.Succeeded) + { + var removePasswordResult = await userManager.RemovePasswordAsync(existingAdminMaster); + if (removePasswordResult.Succeeded) + { + var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword); + EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin."); + } + else + { + var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword); + EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin."); + } + } + } + + if (!await userManager.IsInRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole)) + { + var addRoleResult = await userManager.AddToRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole); + EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial."); } } + } + finally + { + tenantProvider.SetTenantId(previousTenant); + } + } - if (!await userManager.IsInRoleAsync(existingAdmin, "admin")) + private static void EnsureIdentitySucceeded(IdentityResult result, string message) + { + if (result.Succeeded) + { + return; + } + + var details = string.Join("; ", result.Errors.Select(e => e.Description)); + throw new InvalidOperationException($"{message} Detalhes: {details}"); + } + + private static async Task MigrateLegacyRolesAsync(AppDbContext db, RoleManager> roleManager) + { + await MigrateLegacyRoleAsync(db, roleManager, "admin_master", AppRoles.SysAdmin); + await MigrateLegacyRoleAsync(db, roleManager, "admin", AppRoles.SysAdmin); + await MigrateLegacyRoleAsync(db, roleManager, "leitura", AppRoles.Cliente); + await MigrateLegacyRoleAsync(db, roleManager, "operador", AppRoles.Cliente); + } + + private static async Task MigrateLegacyRoleAsync( + AppDbContext db, + RoleManager> roleManager, + string legacyRole, + string newRole) + { + var legacyRoleId = await roleManager.Roles + .Where(r => r.Name == legacyRole) + .Select(r => (Guid?)r.Id) + .FirstOrDefaultAsync(); + if (!legacyRoleId.HasValue) + { + return; + } + + var newRoleId = await roleManager.Roles + .Where(r => r.Name == newRole) + .Select(r => (Guid?)r.Id) + .FirstOrDefaultAsync(); + if (!newRoleId.HasValue) + { + return; + } + + var legacyUserIds = await db.UserRoles + .Where(ur => ur.RoleId == legacyRoleId.Value) + .Select(ur => ur.UserId) + .Distinct() + .ToListAsync(); + if (legacyUserIds.Count == 0) + { + return; + } + + var alreadyInNewRole = await db.UserRoles + .Where(ur => ur.RoleId == newRoleId.Value && legacyUserIds.Contains(ur.UserId)) + .Select(ur => ur.UserId) + .ToListAsync(); + var existingSet = alreadyInNewRole.ToHashSet(); + + foreach (var userId in legacyUserIds) + { + if (!existingSet.Contains(userId)) { - await userManager.AddToRoleAsync(existingAdmin, "admin"); + db.UserRoles.Add(new IdentityUserRole + { + UserId = userId, + RoleId = newRoleId.Value + }); } } - tenantProvider.SetTenantId(null); - } + var legacyAssignments = await db.UserRoles + .Where(ur => ur.RoleId == legacyRoleId.Value) + .ToListAsync(); + if (legacyAssignments.Count > 0) + { + db.UserRoles.RemoveRange(legacyAssignments); + } - private static async Task NormalizeLegacyTenantDataAsync(AppDbContext db, Guid defaultTenantId) - { - if (defaultTenantId == Guid.Empty) - return; + await db.SaveChangesAsync(); - await db.Users - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.MobileLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.MuregLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.BillingClients - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.UserDatas - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.VigenciaLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.TrocaNumeroLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ChipVirgemLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ControleRecebidoLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.Notifications - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoMacrophonyPlans - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoMacrophonyTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoVivoLineResumos - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoVivoLineTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoClienteEspeciais - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoPlanoContratoResumos - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoPlanoContratoTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoLineTotais - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoReservaLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ResumoReservaTotals - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ParcelamentoLines - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ParcelamentoMonthValues - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.AuditLogs - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ImportAuditRuns - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); - - await db.ImportAuditIssues - .IgnoreQueryFilters() - .Where(x => x.TenantId == Guid.Empty) - .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); + var legacyRoleStillUsed = await db.UserRoles.AnyAsync(ur => ur.RoleId == legacyRoleId.Value); + if (!legacyRoleStillUsed) + { + var legacyRoleEntity = await roleManager.Roles.FirstOrDefaultAsync(r => r.Id == legacyRoleId.Value); + if (legacyRoleEntity != null) + { + await roleManager.DeleteAsync(legacyRoleEntity); + } + } } } diff --git a/Dtos/SystemTenantDtos.cs b/Dtos/SystemTenantDtos.cs new file mode 100644 index 0000000..723d9ef --- /dev/null +++ b/Dtos/SystemTenantDtos.cs @@ -0,0 +1,23 @@ +namespace line_gestao_api.Dtos; + +public class SystemTenantListItemDto +{ + public Guid TenantId { get; set; } + public string NomeOficial { get; set; } = string.Empty; +} + +public class CreateSystemTenantUserRequest +{ + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public List Roles { get; set; } = new(); +} + +public class SystemTenantUserCreatedDto +{ + public Guid UserId { get; set; } + public Guid TenantId { get; set; } + public string Email { get; set; } = string.Empty; + public IReadOnlyList Roles { get; set; } = Array.Empty(); +} diff --git a/Migrations/20260226130000_CreateTenantsAndAuditLogsSystemContracts.cs b/Migrations/20260226130000_CreateTenantsAndAuditLogsSystemContracts.cs new file mode 100644 index 0000000..7818b96 --- /dev/null +++ b/Migrations/20260226130000_CreateTenantsAndAuditLogsSystemContracts.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130000_CreateTenantsAndAuditLogsSystemContracts")] + public partial class CreateTenantsAndAuditLogsSystemContracts : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'Name' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'NomeOficial' + ) THEN + ALTER TABLE "Tenants" RENAME COLUMN "Name" TO "NomeOficial"; + END IF; + END + $$; + """); + + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "IsSystem" boolean NOT NULL DEFAULT FALSE;"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "Ativo" boolean NOT NULL DEFAULT TRUE;"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "SourceType" character varying(80) NULL;"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ADD COLUMN IF NOT EXISTS "SourceKey" character varying(300) NULL;"""); + migrationBuilder.Sql(""" + UPDATE "Tenants" + SET "NomeOficial" = COALESCE(NULLIF("NomeOficial", ''), 'TENANT_SEM_NOME') + WHERE "NomeOficial" IS NULL OR "NomeOficial" = ''; + """); + migrationBuilder.Sql("""ALTER TABLE "Tenants" ALTER COLUMN "NomeOficial" SET NOT NULL;"""); + migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_Tenants_SourceType_SourceKey" ON "Tenants" ("SourceType", "SourceKey");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_Tenants_IsSystem_Ativo" ON "Tenants" ("IsSystem", "Ativo");"""); + + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "ActorUserId" uuid NULL;"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "ActorTenantId" uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "TargetTenantId" uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" ADD COLUMN IF NOT EXISTS "MetadataJson" jsonb NOT NULL DEFAULT '{}'::jsonb;"""); + + migrationBuilder.Sql(""" + UPDATE "AuditLogs" + SET "ActorUserId" = COALESCE("ActorUserId", "UserId"), + "ActorTenantId" = CASE + WHEN "ActorTenantId" = '00000000-0000-0000-0000-000000000000' THEN "TenantId" + ELSE "ActorTenantId" + END, + "TargetTenantId" = CASE + WHEN "TargetTenantId" = '00000000-0000-0000-0000-000000000000' THEN "TenantId" + ELSE "TargetTenantId" + END, + "MetadataJson" = COALESCE("MetadataJson", '{}'::jsonb); + """); + + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_ActorTenantId" ON "AuditLogs" ("ActorTenantId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_TargetTenantId" ON "AuditLogs" ("TargetTenantId");"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_AuditLogs_ActorUserId" ON "AuditLogs" ("ActorUserId");"""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_ActorUserId";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_TargetTenantId";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_AuditLogs_ActorTenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "MetadataJson";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "TargetTenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "ActorTenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "AuditLogs" DROP COLUMN IF EXISTS "ActorUserId";"""); + + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Tenants_IsSystem_Ativo";"""); + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_Tenants_SourceType_SourceKey";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "SourceKey";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "SourceType";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "Ativo";"""); + migrationBuilder.Sql("""ALTER TABLE "Tenants" DROP COLUMN IF EXISTS "IsSystem";"""); + + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'NomeOficial' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'Tenants' + AND column_name = 'Name' + ) THEN + ALTER TABLE "Tenants" RENAME COLUMN "NomeOficial" TO "Name"; + END IF; + END + $$; + """); + } + } +} diff --git a/Migrations/20260226130100_AddTenantIdToMobileLinesIfNeeded.cs b/Migrations/20260226130100_AddTenantIdToMobileLinesIfNeeded.cs new file mode 100644 index 0000000..ebc27b8 --- /dev/null +++ b/Migrations/20260226130100_AddTenantIdToMobileLinesIfNeeded.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130100_AddTenantIdToMobileLinesIfNeeded")] + public partial class AddTenantIdToMobileLinesIfNeeded : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'MobileLines' + AND column_name = 'TenantId' + ) THEN + ALTER TABLE "MobileLines" ADD COLUMN "TenantId" uuid NULL; + END IF; + END + $$; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op intencional para evitar perda de dados em bancos legados. + } + } +} diff --git a/Migrations/20260226130200_BackfillTenantsFromDistinctMobileLinesCliente.cs b/Migrations/20260226130200_BackfillTenantsFromDistinctMobileLinesCliente.cs new file mode 100644 index 0000000..01d76a0 --- /dev/null +++ b/Migrations/20260226130200_BackfillTenantsFromDistinctMobileLinesCliente.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130200_BackfillTenantsFromDistinctMobileLinesCliente")] + public partial class BackfillTenantsFromDistinctMobileLinesCliente : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "MobileLines" + WHERE "Cliente" IS NULL OR btrim("Cliente") = '' + ) THEN + RAISE EXCEPTION 'Backfill abortado: MobileLines.Cliente possui valores NULL/vazios. Corrija os dados antes de migrar.'; + END IF; + END + $$; + """); + + migrationBuilder.Sql("""CREATE EXTENSION IF NOT EXISTS pgcrypto;"""); + + migrationBuilder.Sql(""" + INSERT INTO "Tenants" ( + "Id", + "NomeOficial", + "IsSystem", + "Ativo", + "SourceType", + "SourceKey", + "CreatedAt" + ) + SELECT + gen_random_uuid(), + src."Cliente", + FALSE, + TRUE, + 'MobileLines.Cliente', + src."Cliente", + NOW() + FROM ( + SELECT DISTINCT "Cliente" + FROM "MobileLines" + ) src + LEFT JOIN "Tenants" t + ON t."SourceType" = 'MobileLines.Cliente' + AND t."SourceKey" = src."Cliente" + WHERE t."Id" IS NULL; + """); + + migrationBuilder.Sql(""" + UPDATE "Tenants" + SET "NomeOficial" = "SourceKey", + "IsSystem" = FALSE, + "Ativo" = TRUE + WHERE "SourceType" = 'MobileLines.Cliente' + AND "SourceKey" IS NOT NULL; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op intencional. Evita remover tenants já em uso. + } + } +} diff --git a/Migrations/20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey.cs b/Migrations/20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey.cs new file mode 100644 index 0000000..8bb48f1 --- /dev/null +++ b/Migrations/20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130300_BackfillMobileLinesTenantIdFromTenantSourceKey")] + public partial class BackfillMobileLinesTenantIdFromTenantSourceKey : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + UPDATE "MobileLines" m + SET "TenantId" = t."Id" + FROM "Tenants" t + WHERE t."SourceType" = 'MobileLines.Cliente' + AND t."SourceKey" = m."Cliente" + AND (m."TenantId" IS NULL OR m."TenantId" <> t."Id"); + """); + + migrationBuilder.Sql(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM "MobileLines" m + LEFT JOIN "Tenants" t + ON t."SourceType" = 'MobileLines.Cliente' + AND t."SourceKey" = m."Cliente" + WHERE t."Id" IS NULL + ) THEN + RAISE EXCEPTION 'Backfill abortado: existem MobileLines sem tenant correspondente por SourceKey exato.'; + END IF; + END + $$; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // No-op intencional. + } + } +} diff --git a/Migrations/20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes.cs b/Migrations/20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes.cs new file mode 100644 index 0000000..d5097b3 --- /dev/null +++ b/Migrations/20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using line_gestao_api.Data; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260226130400_MakeMobileLinesTenantIdNotNullAndIndexes")] + public partial class MakeMobileLinesTenantIdNotNullAndIndexes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" DROP DEFAULT;"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" SET NOT NULL;"""); + migrationBuilder.Sql("""CREATE INDEX IF NOT EXISTS "IX_MobileLines_TenantId" ON "MobileLines" ("TenantId");"""); + migrationBuilder.Sql("""CREATE UNIQUE INDEX IF NOT EXISTS "IX_MobileLines_TenantId_Linha" ON "MobileLines" ("TenantId", "Linha");"""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("""DROP INDEX IF EXISTS "IX_MobileLines_TenantId";"""); + migrationBuilder.Sql("""ALTER TABLE "MobileLines" ALTER COLUMN "TenantId" DROP NOT NULL;"""); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 2b375cf..6d4fcfb 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -245,6 +245,12 @@ namespace line_gestao_api.Migrations .HasMaxLength(20) .HasColumnType("character varying(20)"); + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("ActorTenantId") + .HasColumnType("uuid"); + b.Property("ChangesJson") .IsRequired() .HasColumnType("jsonb"); @@ -274,6 +280,10 @@ namespace line_gestao_api.Migrations .HasMaxLength(80) .HasColumnType("character varying(80)"); + b.Property("MetadataJson") + .IsRequired() + .HasColumnType("jsonb"); + b.Property("RequestMethod") .HasMaxLength(10) .HasColumnType("character varying(10)"); @@ -285,6 +295,9 @@ namespace line_gestao_api.Migrations b.Property("TenantId") .HasColumnType("uuid"); + b.Property("TargetTenantId") + .HasColumnType("uuid"); + b.Property("UserEmail") .HasMaxLength(200) .HasColumnType("character varying(200)"); @@ -298,6 +311,10 @@ namespace line_gestao_api.Migrations b.HasKey("Id"); + b.HasIndex("ActorUserId"); + + b.HasIndex("ActorTenantId"); + b.HasIndex("EntityName"); b.HasIndex("OccurredAtUtc"); @@ -306,6 +323,8 @@ namespace line_gestao_api.Migrations b.HasIndex("TenantId"); + b.HasIndex("TargetTenantId"); + b.HasIndex("UserId"); b.ToTable("AuditLogs"); @@ -1357,15 +1376,35 @@ namespace line_gestao_api.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Ativo") + .HasColumnType("boolean"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("Name") + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("NomeOficial") .IsRequired() - .HasColumnType("text"); + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("SourceType") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); b.HasKey("Id"); + b.HasIndex("IsSystem", "Ativo"); + + b.HasIndex("SourceType", "SourceKey") + .IsUnique(); + b.ToTable("Tenants"); }); diff --git a/Models/AuditLog.cs b/Models/AuditLog.cs index 2584ff5..1995a7f 100644 --- a/Models/AuditLog.cs +++ b/Models/AuditLog.cs @@ -6,10 +6,16 @@ public class AuditLog : ITenantEntity { public Guid Id { get; set; } = Guid.NewGuid(); + // Compatibilidade com histórico atual + filtro global. public Guid TenantId { get; set; } + public Guid? ActorUserId { get; set; } + public Guid ActorTenantId { get; set; } + public Guid TargetTenantId { get; set; } + public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow; + // Campos legados usados pela tela de histórico. public Guid? UserId { get; set; } public string? UserName { get; set; } public string? UserEmail { get; set; } @@ -21,6 +27,7 @@ public class AuditLog : ITenantEntity public string? EntityLabel { get; set; } public string ChangesJson { get; set; } = "[]"; + public string MetadataJson { get; set; } = "{}"; public string? RequestPath { get; set; } public string? RequestMethod { get; set; } diff --git a/Models/Tenant.cs b/Models/Tenant.cs index 7b70ab7..51542e5 100644 --- a/Models/Tenant.cs +++ b/Models/Tenant.cs @@ -3,6 +3,10 @@ namespace line_gestao_api.Models; public class Tenant { public Guid Id { get; set; } = Guid.NewGuid(); - public string Name { get; set; } = string.Empty; + public string NomeOficial { get; set; } = string.Empty; + public bool IsSystem { get; set; } + public bool Ativo { get; set; } = true; + public string? SourceType { get; set; } + public string? SourceKey { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Program.cs b/Program.cs index 5ad43ac..beec0cf 100644 --- a/Program.cs +++ b/Program.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Security.Claims; using System.Text; using System.Threading.RateLimiting; using line_gestao_api.Data; @@ -91,6 +92,7 @@ builder.Services.AddDbContext(options => builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -144,7 +146,13 @@ builder.Services }; }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("SystemAdmin", policy => + { + policy.RequireRole(SystemTenantConstants.SystemRole); + }); +}); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; @@ -194,3 +202,7 @@ app.MapControllers(); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.Run(); + +public partial class Program +{ +} diff --git a/Services/AppRoles.cs b/Services/AppRoles.cs new file mode 100644 index 0000000..6bc0ff5 --- /dev/null +++ b/Services/AppRoles.cs @@ -0,0 +1,10 @@ +namespace line_gestao_api.Services; + +public static class AppRoles +{ + public const string SysAdmin = "sysadmin"; + public const string Gestor = "gestor"; + public const string Cliente = "cliente"; + + public static readonly string[] All = [SysAdmin, Gestor, Cliente]; +} diff --git a/Services/AuditLogBuilder.cs b/Services/AuditLogBuilder.cs index 3cfac9a..d427e4e 100644 --- a/Services/AuditLogBuilder.cs +++ b/Services/AuditLogBuilder.cs @@ -67,7 +67,7 @@ public class AuditLogBuilder : IAuditLogBuilder public List BuildAuditLogs(ChangeTracker changeTracker) { - var tenantId = _tenantProvider.TenantId; + var tenantId = _tenantProvider.ActorTenantId; if (tenantId == null) { return new List(); @@ -88,6 +88,12 @@ public class AuditLogBuilder : IAuditLogBuilder return new List(); } + if (IsSystemRequest(requestPath)) + { + // Endpoints system usam auditoria explicita com actor/target. + return new List(); + } + var logs = new List(); foreach (var entry in changeTracker.Entries()) @@ -109,6 +115,9 @@ public class AuditLogBuilder : IAuditLogBuilder logs.Add(new AuditLog { TenantId = tenantId.Value, + ActorUserId = userInfo.UserId, + ActorTenantId = tenantId.Value, + TargetTenantId = tenantId.Value, OccurredAtUtc = DateTime.UtcNow, UserId = userInfo.UserId, UserName = userInfo.UserName, @@ -119,6 +128,7 @@ public class AuditLogBuilder : IAuditLogBuilder EntityId = BuildEntityId(entry), EntityLabel = BuildEntityLabel(entry), ChangesJson = JsonSerializer.Serialize(changes, JsonOptions), + MetadataJson = "{}", RequestPath = requestPath, RequestMethod = requestMethod, IpAddress = ipAddress @@ -138,6 +148,16 @@ public class AuditLogBuilder : IAuditLogBuilder return requestPath.Contains("/import-excel", StringComparison.OrdinalIgnoreCase); } + private static bool IsSystemRequest(string? requestPath) + { + if (string.IsNullOrWhiteSpace(requestPath)) + { + return false; + } + + return requestPath.StartsWith("/api/system", StringComparison.OrdinalIgnoreCase); + } + private static string ResolveAction(EntityState state) => state switch { diff --git a/Services/DeterministicGuid.cs b/Services/DeterministicGuid.cs new file mode 100644 index 0000000..26dc405 --- /dev/null +++ b/Services/DeterministicGuid.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; +using System.Text; + +namespace line_gestao_api.Services; + +public static class DeterministicGuid +{ + public static Guid FromString(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + throw new ArgumentException("Valor obrigatório para gerar Guid determinístico.", nameof(input)); + } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + Span bytes = stackalloc byte[16]; + hash.AsSpan(0, 16).CopyTo(bytes); + return new Guid(bytes); + } +} diff --git a/Services/ISystemAuditService.cs b/Services/ISystemAuditService.cs new file mode 100644 index 0000000..1d3f41d --- /dev/null +++ b/Services/ISystemAuditService.cs @@ -0,0 +1,6 @@ +namespace line_gestao_api.Services; + +public interface ISystemAuditService +{ + Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default); +} diff --git a/Services/ITenantProvider.cs b/Services/ITenantProvider.cs index f32efc7..7434de8 100644 --- a/Services/ITenantProvider.cs +++ b/Services/ITenantProvider.cs @@ -2,6 +2,8 @@ namespace line_gestao_api.Services; public interface ITenantProvider { + Guid? ActorTenantId { get; } Guid? TenantId { get; } + bool HasGlobalViewAccess { get; } void SetTenantId(Guid? tenantId); } diff --git a/Services/SystemAuditActions.cs b/Services/SystemAuditActions.cs new file mode 100644 index 0000000..a0fde25 --- /dev/null +++ b/Services/SystemAuditActions.cs @@ -0,0 +1,8 @@ +namespace line_gestao_api.Services; + +public static class SystemAuditActions +{ + public const string ListTenants = "SYSTEM_LIST_TENANTS"; + public const string CreateTenantUser = "SYS_CREATE_USER"; + public const string CreateTenantUserRejected = "SYS_CREATE_USER_ERR"; +} diff --git a/Services/SystemAuditService.cs b/Services/SystemAuditService.cs new file mode 100644 index 0000000..e22be4b --- /dev/null +++ b/Services/SystemAuditService.cs @@ -0,0 +1,115 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using line_gestao_api.Data; +using line_gestao_api.Models; + +namespace line_gestao_api.Services; + +public class SystemAuditService : ISystemAuditService +{ + private const int ActionMaxLength = 20; + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ITenantProvider _tenantProvider; + + public SystemAuditService( + AppDbContext db, + IHttpContextAccessor httpContextAccessor, + ITenantProvider tenantProvider) + { + _db = db; + _httpContextAccessor = httpContextAccessor; + _tenantProvider = tenantProvider; + } + + public async Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default) + { + var actorTenantId = _tenantProvider.ActorTenantId; + if (!actorTenantId.HasValue || actorTenantId.Value == Guid.Empty) + { + return; + } + + var user = _httpContextAccessor.HttpContext?.User; + var userId = ResolveUserId(user); + var userName = ResolveUserName(user); + var userEmail = ResolveUserEmail(user); + + var request = _httpContextAccessor.HttpContext?.Request; + var requestPath = request?.Path.Value; + var requestMethod = request?.Method; + var ipAddress = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString(); + var safeMetadataJson = JsonSerializer.Serialize(metadata ?? new { }, JsonOptions); + var normalizedAction = NormalizeAction(action); + + _db.AuditLogs.Add(new AuditLog + { + TenantId = actorTenantId.Value, + ActorUserId = userId, + ActorTenantId = actorTenantId.Value, + TargetTenantId = targetTenantId, + OccurredAtUtc = DateTime.UtcNow, + Action = normalizedAction, + Page = "System", + EntityName = "System", + EntityId = targetTenantId.ToString(), + EntityLabel = null, + ChangesJson = "[]", + MetadataJson = safeMetadataJson, + UserId = userId, + UserName = userName, + UserEmail = userEmail, + RequestPath = requestPath, + RequestMethod = requestMethod, + IpAddress = ipAddress + }); + + await _db.SaveChangesAsync(cancellationToken); + } + + private static string NormalizeAction(string? action) + { + var normalized = (action ?? string.Empty).Trim().ToUpperInvariant(); + if (string.IsNullOrEmpty(normalized)) + { + return "UNKNOWN"; + } + + if (normalized.Length <= ActionMaxLength) + { + return normalized; + } + + return normalized[..ActionMaxLength]; + } + + private static Guid? ResolveUserId(ClaimsPrincipal? user) + { + var raw = user?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user?.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? user?.FindFirstValue("sub"); + + return Guid.TryParse(raw, out var parsed) ? parsed : null; + } + + private static string? ResolveUserName(ClaimsPrincipal? user) + { + return user?.FindFirstValue("name") + ?? user?.FindFirstValue(ClaimTypes.Name) + ?? user?.Identity?.Name; + } + + private static string? ResolveUserEmail(ClaimsPrincipal? user) + { + return user?.FindFirstValue(ClaimTypes.Email) + ?? user?.FindFirstValue(JwtRegisteredClaimNames.Email) + ?? user?.FindFirstValue("email"); + } +} diff --git a/Services/SystemTenantConstants.cs b/Services/SystemTenantConstants.cs new file mode 100644 index 0000000..e468c09 --- /dev/null +++ b/Services/SystemTenantConstants.cs @@ -0,0 +1,11 @@ +namespace line_gestao_api.Services; + +public static class SystemTenantConstants +{ + public const string SystemTenantSeed = "SYSTEM_TENANT"; + public const string SystemTenantNomeOficial = "SystemTenant"; + public const string SystemRole = AppRoles.SysAdmin; + public const string MobileLinesClienteSourceType = "MobileLines.Cliente"; + + public static readonly Guid SystemTenantId = DeterministicGuid.FromString(SystemTenantSeed); +} diff --git a/Services/TenantProvider.cs b/Services/TenantProvider.cs index 1cf34e5..2e030f4 100644 --- a/Services/TenantProvider.cs +++ b/Services/TenantProvider.cs @@ -13,8 +13,14 @@ public class TenantProvider : ITenantProvider _httpContextAccessor = httpContextAccessor; } + public Guid? ActorTenantId => TenantId; + public Guid? TenantId => CurrentTenant.Value ?? ResolveFromClaims(); + public bool HasGlobalViewAccess => + HasRole(AppRoles.SysAdmin) || + HasRole(AppRoles.Gestor); + public void SetTenantId(Guid? tenantId) { CurrentTenant.Value = tenantId; @@ -27,4 +33,21 @@ public class TenantProvider : ITenantProvider return Guid.TryParse(claim, out var tenantId) ? tenantId : null; } + + private bool HasRole(string role) + { + var principal = _httpContextAccessor.HttpContext?.User; + if (principal?.Identity?.IsAuthenticated != true) + { + return false; + } + + var roleClaims = principal.FindAll(ClaimTypes.Role) + .Select(c => c.Value) + .Concat(principal.FindAll("role").Select(c => c.Value)) + .Concat(principal.FindAll("roles").Select(c => c.Value)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + return roleClaims.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/Services/VigenciaNotificationBackgroundService.cs b/Services/VigenciaNotificationBackgroundService.cs index b6e5d9e..4491e5e 100644 --- a/Services/VigenciaNotificationBackgroundService.cs +++ b/Services/VigenciaNotificationBackgroundService.cs @@ -49,7 +49,10 @@ public class VigenciaNotificationBackgroundService : BackgroundService return; } - var tenants = await db.Tenants.AsNoTracking().ToListAsync(stoppingToken); + var tenants = await db.Tenants + .AsNoTracking() + .Where(t => !t.IsSystem && t.Ativo) + .ToListAsync(stoppingToken); if (tenants.Count == 0) { _logger.LogWarning("Nenhum tenant encontrado para gerar notificações."); diff --git a/appsettings.Development.json b/appsettings.Development.json index ea2b463..22d0f54 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -31,9 +31,8 @@ "Seed": { "Enabled": true, "ReapplyAdminCredentialsOnStartup": true, - "DefaultTenantName": "Default", - "AdminName": "Administrador", - "AdminEmail": "admin@linegestao.local", - "AdminPassword": "DevAdmin123!" + "AdminMasterName": "Admin Master", + "AdminMasterEmail": "admin.master@linegestao.local", + "AdminMasterPassword": "DevAdminMaster123!" } } diff --git a/appsettings.Local.example.json b/appsettings.Local.example.json index b3c813f..6405fe3 100644 --- a/appsettings.Local.example.json +++ b/appsettings.Local.example.json @@ -11,9 +11,8 @@ "Seed": { "Enabled": true, "ReapplyAdminCredentialsOnStartup": false, - "DefaultTenantName": "Default", - "AdminName": "Administrador", - "AdminEmail": "admin@linegestao.local", - "AdminPassword": "YOUR_LOCAL_ADMIN_SEED_PASSWORD" + "AdminMasterName": "Admin Master", + "AdminMasterEmail": "admin.master@linegestao.local", + "AdminMasterPassword": "YOUR_LOCAL_ADMIN_MASTER_SEED_PASSWORD" } } diff --git a/appsettings.json b/appsettings.json index bc7a8bb..2d9078c 100644 --- a/appsettings.json +++ b/appsettings.json @@ -31,9 +31,8 @@ "Seed": { "Enabled": true, "ReapplyAdminCredentialsOnStartup": true, - "DefaultTenantName": "Default", - "AdminName": "Administrador", - "AdminEmail": "admin@linegestao.local", - "AdminPassword": "DevAdmin123!" + "AdminMasterName": "Admin Master", + "AdminMasterEmail": "admin.master@linegestao.local", + "AdminMasterPassword": "DevAdminMaster123!" } -} +} diff --git a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs index 1ff2f1b..e50457d 100644 --- a/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs +++ b/line-gestao-api.Tests/GeralDashboardInsightsServiceTests.cs @@ -148,8 +148,12 @@ namespace line_gestao_api.Tests TenantId = tenantId; } + public Guid? ActorTenantId => TenantId; + public Guid? TenantId { get; private set; } + public bool HasGlobalViewAccess => false; + public void SetTenantId(Guid? tenantId) { TenantId = tenantId; diff --git a/line-gestao-api.Tests/SystemTenantIntegrationTests.cs b/line-gestao-api.Tests/SystemTenantIntegrationTests.cs new file mode 100644 index 0000000..aec2461 --- /dev/null +++ b/line-gestao-api.Tests/SystemTenantIntegrationTests.cs @@ -0,0 +1,335 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using line_gestao_api.Data; +using line_gestao_api.Dtos; +using line_gestao_api.Models; +using line_gestao_api.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace line_gestao_api.Tests; + +public class SystemTenantIntegrationTests +{ + private static readonly Guid TenantAId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid TenantBId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private const string TenantAClientName = "CLIENTE-ALFA LTDA"; + private const string TenantBClientName = "CLIENTE-BETA S/A"; + + [Fact] + public async Task CommonUser_OnlySeesOwnTenantData() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + await UpsertUserAsync(factory.Services, TenantAId, "tenanta.user@test.local", "TenantA123!", "cliente"); + + var token = await LoginAndGetTokenAsync(client, "tenanta.user@test.local", "TenantA123!", TenantAId); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/api/lines/clients"); + response.EnsureSuccessStatusCode(); + + var clients = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(clients); + Assert.Contains(TenantAClientName, clients!); + Assert.DoesNotContain(TenantBClientName, clients); + } + + [Fact] + public async Task CommonUser_CannotAccessSystemEndpoints() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + await UpsertUserAsync(factory.Services, TenantAId, "tenanta.block@test.local", "TenantA123!", "cliente"); + + var token = await LoginAndGetTokenAsync(client, "tenanta.block@test.local", "TenantA123!", TenantAId); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await client.GetAsync("/api/system/tenants?source=MobileLines.Cliente&active=true"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task SysAdmin_CanListClientTenants() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + + var token = await LoginAndGetTokenAsync( + client, + "admin.master@test.local", + "AdminMaster123!", + SystemTenantConstants.SystemTenantId); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + var response = await client.GetAsync("/api/system/tenants?source=MobileLines.Cliente&active=true"); + response.EnsureSuccessStatusCode(); + + var tenants = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(tenants); + Assert.Contains(tenants!, t => t.TenantId == TenantAId && t.NomeOficial == TenantAClientName); + Assert.Contains(tenants, t => t.TenantId == TenantBId && t.NomeOficial == TenantBClientName); + } + + [Fact] + public async Task SysAdmin_CreatesTenantUser_AndNewUserSeesOnlyOwnTenant() + { + using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + await SeedTenantsAndLinesAsync(factory.Services); + + var adminToken = await LoginAndGetTokenAsync( + client, + "admin.master@test.local", + "AdminMaster123!", + SystemTenantConstants.SystemTenantId); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + var request = new CreateSystemTenantUserRequest + { + Name = "Usuário Cliente A", + Email = "novo.clientea@test.local", + Password = "ClienteA123!", + Roles = new List { "cliente" } + }; + + var createResponse = await client.PostAsJsonAsync($"/api/system/tenants/{TenantAId}/users", request); + createResponse.EnsureSuccessStatusCode(); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal(TenantAId, created!.TenantId); + Assert.Equal("novo.clientea@test.local", created.Email); + Assert.Contains("cliente", created.Roles); + + var userToken = await LoginAndGetTokenAsync(client, "novo.clientea@test.local", "ClienteA123!", TenantAId); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", userToken); + + var visibleClientsResponse = await client.GetAsync("/api/lines/clients"); + visibleClientsResponse.EnsureSuccessStatusCode(); + var clients = await visibleClientsResponse.Content.ReadFromJsonAsync>(); + + Assert.NotNull(clients); + Assert.Contains(TenantAClientName, clients!); + Assert.DoesNotContain(TenantBClientName, clients); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var systemAudit = await db.AuditLogs + .IgnoreQueryFilters() + .OrderByDescending(x => x.OccurredAtUtc) + .FirstOrDefaultAsync(x => x.Action == SystemAuditActions.CreateTenantUser); + + Assert.NotNull(systemAudit); + Assert.Equal(SystemTenantConstants.SystemTenantId, systemAudit!.ActorTenantId); + Assert.Equal(TenantAId, systemAudit.TargetTenantId); + Assert.DoesNotContain("ClienteA123!", systemAudit.MetadataJson); + } + + private static async Task SeedTenantsAndLinesAsync(IServiceProvider services) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var tenantA = await db.Tenants.FirstOrDefaultAsync(t => t.Id == TenantAId); + if (tenantA == null) + { + db.Tenants.Add(new Tenant + { + Id = TenantAId, + NomeOficial = TenantAClientName, + IsSystem = false, + Ativo = true, + SourceType = SystemTenantConstants.MobileLinesClienteSourceType, + SourceKey = TenantAClientName, + CreatedAt = DateTime.UtcNow + }); + } + + var tenantB = await db.Tenants.FirstOrDefaultAsync(t => t.Id == TenantBId); + if (tenantB == null) + { + db.Tenants.Add(new Tenant + { + Id = TenantBId, + NomeOficial = TenantBClientName, + IsSystem = false, + Ativo = true, + SourceType = SystemTenantConstants.MobileLinesClienteSourceType, + SourceKey = TenantBClientName, + CreatedAt = DateTime.UtcNow + }); + } + + var currentLines = await db.MobileLines.IgnoreQueryFilters().ToListAsync(); + if (currentLines.Count > 0) + { + db.MobileLines.RemoveRange(currentLines); + } + + db.MobileLines.AddRange( + new MobileLine + { + Id = Guid.NewGuid(), + Item = 1, + Linha = "5511999990001", + Cliente = TenantAClientName, + TenantId = TenantAId + }, + new MobileLine + { + Id = Guid.NewGuid(), + Item = 2, + Linha = "5511888880002", + Cliente = TenantBClientName, + TenantId = TenantBId + }); + + await db.SaveChangesAsync(); + } + + private static async Task UpsertUserAsync( + IServiceProvider services, + Guid tenantId, + string email, + string password, + params string[] roles) + { + await using var scope = services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var tenantProvider = scope.ServiceProvider.GetRequiredService(); + + var previousTenant = tenantProvider.ActorTenantId; + tenantProvider.SetTenantId(tenantId); + + try + { + var normalizedEmail = userManager.NormalizeEmail(email); + var user = await userManager.Users + .IgnoreQueryFilters() + .FirstOrDefaultAsync(u => u.TenantId == tenantId && u.NormalizedEmail == normalizedEmail); + + if (user == null) + { + user = new ApplicationUser + { + Name = email, + Email = email, + UserName = email, + TenantId = tenantId, + EmailConfirmed = true, + IsActive = true, + LockoutEnabled = true + }; + + var createResult = await userManager.CreateAsync(user, password); + Assert.True(createResult.Succeeded, string.Join("; ", createResult.Errors.Select(e => e.Description))); + } + else + { + var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); + var reset = await userManager.ResetPasswordAsync(user, resetToken, password); + Assert.True(reset.Succeeded, string.Join("; ", reset.Errors.Select(e => e.Description))); + } + + var existingRoles = await userManager.GetRolesAsync(user); + if (existingRoles.Count > 0) + { + var removeRolesResult = await userManager.RemoveFromRolesAsync(user, existingRoles); + Assert.True(removeRolesResult.Succeeded, string.Join("; ", removeRolesResult.Errors.Select(e => e.Description))); + } + + var addRolesResult = await userManager.AddToRolesAsync(user, roles); + Assert.True(addRolesResult.Succeeded, string.Join("; ", addRolesResult.Errors.Select(e => e.Description))); + } + finally + { + tenantProvider.SetTenantId(previousTenant); + } + } + + private static async Task LoginAndGetTokenAsync(HttpClient client, string email, string password, Guid tenantId) + { + var previousAuth = client.DefaultRequestHeaders.Authorization; + client.DefaultRequestHeaders.Authorization = null; + + try + { + var response = await client.PostAsJsonAsync("/auth/login", new + { + email, + password, + tenantId + }); + + response.EnsureSuccessStatusCode(); + var auth = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(auth); + Assert.False(string.IsNullOrWhiteSpace(auth!.Token)); + return auth.Token; + } + finally + { + client.DefaultRequestHeaders.Authorization = previousAuth; + } + } + + private sealed class ApiFactory : WebApplicationFactory + { + private readonly string _databaseName = $"line-gestao-tests-{Guid.NewGuid()}"; + + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["App:UseHttpsRedirection"] = "false", + ["Seed:Enabled"] = "true", + ["Seed:AdminMasterName"] = "Admin Master", + ["Seed:AdminMasterEmail"] = "admin.master@test.local", + ["Seed:AdminMasterPassword"] = "AdminMaster123!", + ["Seed:ReapplyAdminCredentialsOnStartup"] = "true" + }); + }); + + builder.ConfigureServices(services => + { + var notificationHostedService = services + .Where(d => d.ServiceType == typeof(IHostedService) && + d.ImplementationType == typeof(VigenciaNotificationBackgroundService)) + .ToList(); + foreach (var descriptor in notificationHostedService) + { + services.Remove(descriptor); + } + + services.RemoveAll(); + services.RemoveAll>(); + + services.AddDbContext(options => + { + options.UseInMemoryDatabase(_databaseName); + }); + }); + } + } +} diff --git a/line-gestao-api.Tests/line-gestao-api.Tests.csproj b/line-gestao-api.Tests/line-gestao-api.Tests.csproj index c799d69..109e103 100644 --- a/line-gestao-api.Tests/line-gestao-api.Tests.csproj +++ b/line-gestao-api.Tests/line-gestao-api.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/postman/SystemTenant-MultiTenant-Tests.postman_collection.json b/postman/SystemTenant-MultiTenant-Tests.postman_collection.json new file mode 100644 index 0000000..4a627d6 --- /dev/null +++ b/postman/SystemTenant-MultiTenant-Tests.postman_collection.json @@ -0,0 +1,395 @@ +{ + "info": { + "name": "Line Gestao - SystemTenant Multi-tenant Tests", + "_postman_id": "c4c0b7d9-7f11-4a0c-b8ca-332633f12601", + "description": "Fluxo de testes para sysadmin, endpoints /api/system/* e isolamento por tenant.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "adminMasterToken", "value": "" }, + { "key": "tenantAUserToken", "value": "" }, + { "key": "newTenantAUserToken", "value": "" }, + { "key": "tenantAUserId", "value": "" }, + { "key": "newTenantAUserId", "value": "" }, + { "key": "newTenantAUserEmail", "value": "" }, + { "key": "newTenantAUserPassword", "value": "" }, + { "key": "newTenantAUserName", "value": "" } + ], + "item": [ + { + "name": "1) Login sysadmin", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{adminMasterEmail}}\",\n \"password\": \"{{adminMasterPassword}}\",\n \"tenantId\": \"{{systemTenantId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const json = pm.response.json();", + "pm.test('Retorna token JWT', function () {", + " pm.expect(json.token).to.be.a('string').and.not.empty;", + "});", + "pm.collectionVariables.set('adminMasterToken', json.token);" + ] + } + } + ] + }, + { + "name": "2) GET /api/system/tenants (sysadmin)", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{adminMasterToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/system/tenants?source=MobileLines.Cliente&active=true", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants"], + "query": [ + { "key": "source", "value": "MobileLines.Cliente" }, + { "key": "active", "value": "true" } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const tenants = pm.response.json();", + "pm.test('Retorna array de tenants', function () {", + " pm.expect(Array.isArray(tenants)).to.eql(true);", + "});", + "const tenantAClientName = pm.environment.get('tenantAClientName');", + "const tenantBClientName = pm.environment.get('tenantBClientName');", + "if (tenantAClientName) {", + " const tenantA = tenants.find(t => t.nomeOficial === tenantAClientName || t.NomeOficial === tenantAClientName);", + " pm.test('Tenant A encontrado por nomeOficial', function () {", + " pm.expect(tenantA).to.exist;", + " });", + " if (tenantA && (tenantA.tenantId || tenantA.TenantId)) {", + " pm.environment.set('tenantAId', tenantA.tenantId || tenantA.TenantId);", + " }", + "}", + "if (tenantBClientName) {", + " const tenantB = tenants.find(t => t.nomeOficial === tenantBClientName || t.NomeOficial === tenantBClientName);", + " pm.test('Tenant B encontrado por nomeOficial', function () {", + " pm.expect(tenantB).to.exist;", + " });", + " if (tenantB && (tenantB.tenantId || tenantB.TenantId)) {", + " pm.environment.set('tenantBId', tenantB.tenantId || tenantB.TenantId);", + " }", + "}" + ] + } + } + ] + }, + { + "name": "3) POST /api/system/tenants/{tenantId}/users (criar usuário comum tenant A)", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{adminMasterToken}}", "type": "string" } + ] + }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"{{tenantAUserName}}\",\n \"email\": \"{{tenantAUserEmail}}\",\n \"password\": \"{{tenantAUserPassword}}\",\n \"roles\": [\"cliente\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/system/tenants/{{tenantAId}}/users", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants", "{{tenantAId}}", "users"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 201 (criado) ou 409 (já existe)', function () {", + " pm.expect([201, 409]).to.include(pm.response.code);", + "});", + "if (pm.response.code === 201) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('tenantAUserId', json.userId || json.UserId || '');", + "}" + ] + } + } + ] + }, + { + "name": "4) Login usuário comum tenant A", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{tenantAUserEmail}}\",\n \"password\": \"{{tenantAUserPassword}}\",\n \"tenantId\": \"{{tenantAId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const json = pm.response.json();", + "pm.collectionVariables.set('tenantAUserToken', json.token);" + ] + } + } + ] + }, + { + "name": "5) Usuário comum NÃO acessa /api/system/*", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{tenantAUserToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/system/tenants?source=MobileLines.Cliente&active=true", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants"], + "query": [ + { "key": "source", "value": "MobileLines.Cliente" }, + { "key": "active", "value": "true" } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 403 Forbidden', function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ] + }, + { + "name": "6) Usuário comum tenant A vê apenas seu tenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{tenantAUserToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/lines/clients", + "host": ["{{baseUrl}}"], + "path": ["api", "lines", "clients"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const clients = pm.response.json();", + "pm.test('Retorna lista de clientes', function () {", + " pm.expect(Array.isArray(clients)).to.eql(true);", + "});", + "const tenantAClientName = pm.environment.get('tenantAClientName');", + "const tenantBClientName = pm.environment.get('tenantBClientName');", + "if (tenantAClientName) {", + " pm.test('Contém cliente do tenant A', function () {", + " pm.expect(clients).to.include(tenantAClientName);", + " });", + "}", + "if (tenantBClientName) {", + " pm.test('Não contém cliente do tenant B', function () {", + " pm.expect(clients).to.not.include(tenantBClientName);", + " });", + "}" + ] + } + } + ] + }, + { + "name": "7) POST /api/system/tenants/{tenantId}/users (novo usuário tenant A)", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const suffix = Date.now().toString().slice(-8);", + "pm.collectionVariables.set('newTenantAUserEmail', `novo.tenant.a.${suffix}@test.local`);", + "pm.collectionVariables.set('newTenantAUserPassword', 'ClienteA123!');", + "pm.collectionVariables.set('newTenantAUserName', `Novo Tenant A ${suffix}`);" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 201', function () {", + " pm.response.to.have.status(201);", + "});", + "const json = pm.response.json();", + "pm.collectionVariables.set('newTenantAUserId', json.userId || json.UserId || '');" + ] + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{adminMasterToken}}", "type": "string" } + ] + }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"{{newTenantAUserName}}\",\n \"email\": \"{{newTenantAUserEmail}}\",\n \"password\": \"{{newTenantAUserPassword}}\",\n \"roles\": [\"cliente\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/system/tenants/{{tenantAId}}/users", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "tenants", "{{tenantAId}}", "users"] + } + } + }, + { + "name": "8) Login novo usuário tenant A", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"{{newTenantAUserEmail}}\",\n \"password\": \"{{newTenantAUserPassword}}\",\n \"tenantId\": \"{{tenantAId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const json = pm.response.json();", + "pm.collectionVariables.set('newTenantAUserToken', json.token);" + ] + } + } + ] + }, + { + "name": "9) Novo usuário tenant A vê apenas seu tenant", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{newTenantAUserToken}}", "type": "string" } + ] + }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/lines/clients", + "host": ["{{baseUrl}}"], + "path": ["api", "lines", "clients"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status 200', function () {", + " pm.response.to.have.status(200);", + "});", + "const clients = pm.response.json();", + "const tenantAClientName = pm.environment.get('tenantAClientName');", + "const tenantBClientName = pm.environment.get('tenantBClientName');", + "if (tenantAClientName) {", + " pm.test('Contém cliente do tenant A', function () {", + " pm.expect(clients).to.include(tenantAClientName);", + " });", + "}", + "if (tenantBClientName) {", + " pm.test('Não contém cliente do tenant B', function () {", + " pm.expect(clients).to.not.include(tenantBClientName);", + " });", + "}" + ] + } + } + ] + } + ] +} diff --git a/postman/SystemTenant-MultiTenant-Tests.postman_environment.json b/postman/SystemTenant-MultiTenant-Tests.postman_environment.json new file mode 100644 index 0000000..ebb2ee1 --- /dev/null +++ b/postman/SystemTenant-MultiTenant-Tests.postman_environment.json @@ -0,0 +1,64 @@ +{ + "id": "d1d8e905-e4b8-40c5-a62e-afb27c59b685", + "name": "Line Gestao - Local", + "values": [ + { + "key": "baseUrl", + "value": "http://localhost:5000", + "enabled": true + }, + { + "key": "systemTenantId", + "value": "562617c4-90dc-cfce-ddf4-64b6284dc4f2", + "enabled": true + }, + { + "key": "adminMasterEmail", + "value": "sysadmin@linegestao.local", + "enabled": true + }, + { + "key": "adminMasterPassword", + "value": "", + "enabled": true + }, + { + "key": "tenantAId", + "value": "", + "enabled": true + }, + { + "key": "tenantBId", + "value": "", + "enabled": true + }, + { + "key": "tenantAClientName", + "value": "", + "enabled": true + }, + { + "key": "tenantBClientName", + "value": "", + "enabled": true + }, + { + "key": "tenantAUserName", + "value": "Usuario Tenant A", + "enabled": true + }, + { + "key": "tenantAUserEmail", + "value": "tenanta.user@test.local", + "enabled": true + }, + { + "key": "tenantAUserPassword", + "value": "TenantA123!", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-02-26T12:00:00.000Z", + "_postman_exported_using": "Codex GPT-5" +}