Feat: Corrigindo merge

This commit is contained in:
Eduardo 2026-02-27 16:54:20 -03:00
parent 7a7b5db73e
commit 242f8bc707
46 changed files with 2015 additions and 298 deletions

3
.gitignore vendored
View File

@ -6,6 +6,9 @@
# dotenv files # dotenv files
.env .env
appsettings.Local.json appsettings.Local.json
appsettings*.json
line-gestao-api.csproj
line-gestao-api.http
# User-specific files # User-specific files
*.rsuser *.rsuser

View File

@ -36,7 +36,7 @@ public class AuthController : ControllerBase
} }
[HttpPost("register")] [HttpPost("register")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Register(RegisterRequest req) public async Task<IActionResult> Register(RegisterRequest req)
{ {
if (req.Password != req.ConfirmPassword) if (req.Password != req.ConfirmPassword)
@ -69,7 +69,7 @@ public class AuthController : ControllerBase
if (!createResult.Succeeded) if (!createResult.Succeeded)
return BadRequest(createResult.Errors.Select(e => e.Description).ToList()); 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); var effectiveTenantId = await EnsureValidTenantIdAsync(user);
if (!effectiveTenantId.HasValue) if (!effectiveTenantId.HasValue)
@ -192,22 +192,19 @@ public class AuthController : ControllerBase
private async Task<Guid?> EnsureValidTenantIdAsync(ApplicationUser user) private async Task<Guid?> EnsureValidTenantIdAsync(ApplicationUser user)
{ {
if (user.TenantId != Guid.Empty) if (user.TenantId == Guid.Empty)
return user.TenantId; {
return null;
}
var fallbackTenantId = await _db.Tenants var existsAndActive = await _db.Tenants
.AsNoTracking() .AsNoTracking()
.OrderBy(t => t.CreatedAt) .AnyAsync(t => t.Id == user.TenantId && t.Ativo);
.Select(t => (Guid?)t.Id)
.FirstOrDefaultAsync();
if (!fallbackTenantId.HasValue || fallbackTenantId.Value == Guid.Empty) if (!existsAndActive)
return null; {
user.TenantId = fallbackTenantId.Value;
var updateResult = await _userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return null; return null;
}
return user.TenantId; return user.TenantId;
} }

View File

@ -10,7 +10,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public class BillingController : ControllerBase public class BillingController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -197,7 +197,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBillingClientRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBillingClientRequest req)
{ {
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);
@ -230,7 +230,7 @@ namespace line_gestao_api.Controllers
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.BillingClients.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/chips-virgens")] [Route("api/chips-virgens")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public class ChipsVirgensController : ControllerBase public class ChipsVirgensController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -93,7 +93,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<ChipVirgemDetailDto>> Create([FromBody] CreateChipVirgemDto req) public async Task<ActionResult<ChipVirgemDetailDto>> Create([FromBody] CreateChipVirgemDto req)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -122,7 +122,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateChipVirgemRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateChipVirgemRequest req)
{ {
var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id);
@ -139,7 +139,7 @@ namespace line_gestao_api.Controllers
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.ChipVirgemLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/controle-recebidos")] [Route("api/controle-recebidos")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public class ControleRecebidosController : ControllerBase public class ControleRecebidosController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -138,7 +138,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<ControleRecebidoDetailDto>> Create([FromBody] CreateControleRecebidoDto req) public async Task<ActionResult<ControleRecebidoDetailDto>> Create([FromBody] CreateControleRecebidoDto req)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -195,7 +195,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateControleRecebidoRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateControleRecebidoRequest req)
{ {
var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id);
@ -223,7 +223,7 @@ namespace line_gestao_api.Controllers
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.ControleRecebidoLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -11,7 +11,7 @@ namespace line_gestao_api.Controllers;
[ApiController] [ApiController]
[Route("api/historico")] [Route("api/historico")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public class HistoricoController : ControllerBase public class HistoricoController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;

View File

@ -105,6 +105,9 @@ namespace line_gestao_api.Controllers
query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
} }
if (!reservaFilter)
query = ExcludeReservaContext(query);
query = ApplyAdditionalFilters(query, additionalMode, additionalServices); query = ApplyAdditionalFilters(query, additionalMode, additionalServices);
IQueryable<ClientGroupDto> groupedQuery; IQueryable<ClientGroupDto> groupedQuery;
@ -232,6 +235,9 @@ namespace line_gestao_api.Controllers
query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
} }
if (!reservaFilter)
query = ExcludeReservaContext(query);
query = ApplyAdditionalFilters(query, additionalMode, additionalServices); query = ApplyAdditionalFilters(query, additionalMode, additionalServices);
List<string> clients; List<string> clients;
@ -468,6 +474,9 @@ namespace line_gestao_api.Controllers
q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%"));
} }
if (!reservaFilter)
q = ExcludeReservaContext(q);
q = ApplyAdditionalFilters(q, additionalMode, additionalServices); q = ApplyAdditionalFilters(q, additionalMode, additionalServices);
var sb = (sortBy ?? "item").Trim().ToLowerInvariant(); var sb = (sortBy ?? "item").Trim().ToLowerInvariant();
@ -686,7 +695,7 @@ namespace line_gestao_api.Controllers
// ✅ 5. CREATE // ✅ 5. CREATE
// ========================================================== // ==========================================================
[HttpPost] [HttpPost]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<MobileLineDetailDto>> Create([FromBody] CreateMobileLineDto req) public async Task<ActionResult<MobileLineDetailDto>> Create([FromBody] CreateMobileLineDto req)
{ {
if (string.IsNullOrWhiteSpace(req.Cliente)) if (string.IsNullOrWhiteSpace(req.Cliente))
@ -796,7 +805,7 @@ namespace line_gestao_api.Controllers
// ✅ 5.1. CREATE BATCH // ✅ 5.1. CREATE BATCH
// ========================================================== // ==========================================================
[HttpPost("batch")] [HttpPost("batch")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<CreateMobileLinesBatchResultDto>> CreateBatch([FromBody] CreateMobileLinesBatchRequestDto req) public async Task<ActionResult<CreateMobileLinesBatchResultDto>> CreateBatch([FromBody] CreateMobileLinesBatchRequestDto req)
{ {
var requests = req?.Lines ?? new List<CreateMobileLineDto>(); var requests = req?.Lines ?? new List<CreateMobileLineDto>();
@ -976,7 +985,7 @@ namespace line_gestao_api.Controllers
// ✅ 5.2. PREVIEW IMPORTAÇÃO EXCEL PARA LOTE (ABA GERAL) // ✅ 5.2. PREVIEW IMPORTAÇÃO EXCEL PARA LOTE (ABA GERAL)
// ========================================================== // ==========================================================
[HttpPost("batch/import-preview")] [HttpPost("batch/import-preview")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
[Consumes("multipart/form-data")] [Consumes("multipart/form-data")]
[RequestSizeLimit(20_000_000)] [RequestSizeLimit(20_000_000)]
public async Task<ActionResult<LinesBatchExcelPreviewResultDto>> PreviewBatchImportExcel([FromForm] ImportExcelForm form) public async Task<ActionResult<LinesBatchExcelPreviewResultDto>> PreviewBatchImportExcel([FromForm] ImportExcelForm form)
@ -1006,7 +1015,7 @@ namespace line_gestao_api.Controllers
// ✅ 5.3. ATRIBUIR LINHAS DA RESERVA PARA CLIENTE // ✅ 5.3. ATRIBUIR LINHAS DA RESERVA PARA CLIENTE
// ========================================================== // ==========================================================
[HttpPost("reserva/assign-client")] [HttpPost("reserva/assign-client")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<AssignReservaLinesResultDto>> AssignReservaLinesToClient([FromBody] AssignReservaLinesRequestDto req) public async Task<ActionResult<AssignReservaLinesResultDto>> AssignReservaLinesToClient([FromBody] AssignReservaLinesRequestDto req)
{ {
var ids = (req?.LineIds ?? new List<Guid>()) var ids = (req?.LineIds ?? new List<Guid>())
@ -1146,7 +1155,7 @@ namespace line_gestao_api.Controllers
// ✅ 5.4. MOVER LINHAS DE CLIENTE PARA RESERVA // ✅ 5.4. MOVER LINHAS DE CLIENTE PARA RESERVA
// ========================================================== // ==========================================================
[HttpPost("move-to-reserva")] [HttpPost("move-to-reserva")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<AssignReservaLinesResultDto>> MoveLinesToReserva([FromBody] MoveLinesToReservaRequestDto req) public async Task<ActionResult<AssignReservaLinesResultDto>> MoveLinesToReserva([FromBody] MoveLinesToReservaRequestDto req)
{ {
var ids = (req?.LineIds ?? new List<Guid>()) var ids = (req?.LineIds ?? new List<Guid>())
@ -1251,7 +1260,7 @@ namespace line_gestao_api.Controllers
// ✅ 6. UPDATE // ✅ 6. UPDATE
// ========================================================== // ==========================================================
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMobileLineRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMobileLineRequest req)
{ {
var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id);
@ -1345,7 +1354,7 @@ namespace line_gestao_api.Controllers
// ✅ 7. DELETE // ✅ 7. DELETE
// ========================================================== // ==========================================================
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id);
@ -1360,7 +1369,7 @@ namespace line_gestao_api.Controllers
// ✅ 8. IMPORT EXCEL // ✅ 8. IMPORT EXCEL
// ========================================================== // ==========================================================
[HttpPost("import-excel")] [HttpPost("import-excel")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
[Consumes("multipart/form-data")] [Consumes("multipart/form-data")]
[RequestSizeLimit(50_000_000)] [RequestSizeLimit(50_000_000)]
public async Task<ActionResult<ImportResultDto>> ImportExcel([FromForm] ImportExcelForm form) public async Task<ActionResult<ImportResultDto>> ImportExcel([FromForm] ImportExcelForm form)
@ -4336,6 +4345,14 @@ namespace line_gestao_api.Controllers
}); });
} }
private static IQueryable<MobileLine> ExcludeReservaContext(IQueryable<MobileLine> 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<MobileLine> ApplyAdditionalFilters( private static IQueryable<MobileLine> ApplyAdditionalFilters(
IQueryable<MobileLine> query, IQueryable<MobileLine> query,
string? additionalMode, string? additionalMode,

View File

@ -187,7 +187,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<MuregDetailDto>> Create([FromBody] CreateMuregDto req) public async Task<ActionResult<MuregDetailDto>> Create([FromBody] CreateMuregDto req)
{ {
if (req.MobileLineId == Guid.Empty) if (req.MobileLineId == Guid.Empty)
@ -289,7 +289,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMuregDto req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMuregDto req)
{ {
var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id); var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == id);
@ -361,7 +361,7 @@ namespace line_gestao_api.Controllers
// Exclui registro MUREG // Exclui registro MUREG
// ========================================================== // ==========================================================
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var entity = await _db.MuregLines.FirstOrDefaultAsync(x => x.Id == 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) // ✅ POST: /api/mureg/import-excel (mantido)
// ========================================================== // ==========================================================
[HttpPost("import-excel")] [HttpPost("import-excel")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
[Consumes("multipart/form-data")] [Consumes("multipart/form-data")]
[RequestSizeLimit(50_000_000)] [RequestSizeLimit(50_000_000)]
public async Task<IActionResult> ImportExcel([FromForm] ImportExcelForm form) public async Task<IActionResult> ImportExcel([FromForm] ImportExcelForm form)

View File

@ -9,7 +9,7 @@ namespace line_gestao_api.Controllers;
[ApiController] [ApiController]
[Route("api/parcelamentos")] [Route("api/parcelamentos")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public class ParcelamentosController : ControllerBase public class ParcelamentosController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -165,7 +165,7 @@ public class ParcelamentosController : ControllerBase
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<ParcelamentoDetailDto>> Create([FromBody] ParcelamentoUpsertDto req) public async Task<ActionResult<ParcelamentoDetailDto>> Create([FromBody] ParcelamentoUpsertDto req)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -202,7 +202,7 @@ public class ParcelamentosController : ControllerBase
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] ParcelamentoUpsertDto req) public async Task<IActionResult> Update(Guid id, [FromBody] ParcelamentoUpsertDto req)
{ {
var entity = await _db.ParcelamentoLines var entity = await _db.ParcelamentoLines
@ -239,7 +239,7 @@ public class ParcelamentosController : ControllerBase
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id); var entity = await _db.ParcelamentoLines.FirstOrDefaultAsync(x => x.Id == id);

View File

@ -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<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole<Guid>> _roleManager;
private readonly ISystemAuditService _systemAuditService;
public SystemTenantUsersController(
AppDbContext db,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole<Guid>> roleManager,
ISystemAuditService systemAuditService)
{
_db = db;
_userManager = userManager;
_roleManager = roleManager;
_systemAuditService = systemAuditService;
}
[HttpPost]
public async Task<ActionResult<SystemTenantUserCreatedDto>> 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<ActionResult<SystemTenantUserCreatedDto>> 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);
}
}

View File

@ -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<ActionResult<IReadOnlyList<SystemTenantListItemDto>>> 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);
}
}

View File

@ -6,7 +6,7 @@ namespace line_gestao_api.Controllers
{ {
[ApiController] [ApiController]
[Route("api/templates")] [Route("api/templates")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public class TemplatesController : ControllerBase public class TemplatesController : ControllerBase
{ {
private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService; private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService;

View File

@ -112,7 +112,7 @@ namespace line_gestao_api.Controllers
// ✅ CREATE // ✅ CREATE
// ========================================================== // ==========================================================
[HttpPost] [HttpPost]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<ActionResult<TrocaNumeroDetailDto>> Create([FromBody] CreateTrocaNumeroDto req) public async Task<ActionResult<TrocaNumeroDetailDto>> Create([FromBody] CreateTrocaNumeroDto req)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -141,7 +141,7 @@ namespace line_gestao_api.Controllers
// ✅ UPDATE // ✅ UPDATE
// ========================================================== // ==========================================================
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin,gestor")] [Authorize(Roles = "sysadmin,gestor")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateTrocaNumeroRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateTrocaNumeroRequest req)
{ {
var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id);
@ -167,7 +167,7 @@ namespace line_gestao_api.Controllers
// ✅ DELETE // ✅ DELETE
// ========================================================== // ==========================================================
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.TrocaNumeroLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -263,7 +263,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<ActionResult<UserDataDetailDto>> Create([FromBody] CreateUserDataRequest req) public async Task<ActionResult<UserDataDetailDto>> Create([FromBody] CreateUserDataRequest req)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -365,7 +365,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserDataRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserDataRequest req)
{ {
var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id);
@ -397,7 +397,7 @@ namespace line_gestao_api.Controllers
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.UserDatas.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -17,8 +17,9 @@ public class UsersController : ControllerBase
{ {
private static readonly HashSet<string> AllowedRoles = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> AllowedRoles = new(StringComparer.OrdinalIgnoreCase)
{ {
"admin", AppRoles.SysAdmin,
"gestor" AppRoles.Gestor,
AppRoles.Cliente
}; };
private readonly AppDbContext _db; private readonly AppDbContext _db;
@ -39,7 +40,7 @@ public class UsersController : ControllerBase
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<ActionResult<UserListItemDto>> Create([FromBody] UserCreateRequest req) public async Task<ActionResult<UserListItemDto>> Create([FromBody] UserCreateRequest req)
{ {
var errors = ValidateCreate(req); var errors = ValidateCreate(req);
@ -122,7 +123,7 @@ public class UsersController : ControllerBase
} }
[HttpGet] [HttpGet]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<ActionResult<PagedResult<UserListItemDto>>> GetAll( public async Task<ActionResult<PagedResult<UserListItemDto>>> GetAll(
[FromQuery] string? search, [FromQuery] string? search,
[FromQuery] string? permissao, [FromQuery] string? permissao,
@ -191,7 +192,7 @@ public class UsersController : ControllerBase
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<ActionResult<UserListItemDto>> GetById(Guid id) public async Task<ActionResult<UserListItemDto>> GetById(Guid id)
{ {
var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id); var user = await _userManager.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
@ -215,7 +216,7 @@ public class UsersController : ControllerBase
} }
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UserUpdateRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UserUpdateRequest req)
{ {
var errors = await ValidateUpdateAsync(id, req); var errors = await ValidateUpdateAsync(id, req);
@ -295,7 +296,7 @@ public class UsersController : ControllerBase
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
if (_tenantProvider.TenantId == null) if (_tenantProvider.TenantId == null)
@ -334,12 +335,12 @@ public class UsersController : ControllerBase
} }
var targetRoles = await _userManager.GetRolesAsync(user); 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) if (isAdmin)
{ {
var adminRoleId = await _roleManager.Roles var adminRoleId = await _roleManager.Roles
.Where(r => r.Name == "admin") .Where(r => r.Name == AppRoles.SysAdmin)
.Select(r => (Guid?)r.Id) .Select(r => (Guid?)r.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@ -360,7 +361,7 @@ public class UsersController : ControllerBase
{ {
Errors = new List<ValidationErrorDto> Errors = new List<ValidationErrorDto>
{ {
new() { Field = "usuario", Message = "Não é permitido excluir o último administrador." } new() { Field = "usuario", Message = "Não é permitido excluir o último sysadmin." }
} }
}); });
} }

View File

@ -256,7 +256,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPost] [HttpPost]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<ActionResult<VigenciaLineDetailDto>> Create([FromBody] CreateVigenciaRequest req) public async Task<ActionResult<VigenciaLineDetailDto>> Create([FromBody] CreateVigenciaRequest req)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@ -354,7 +354,7 @@ namespace line_gestao_api.Controllers
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVigenciaRequest req) public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVigenciaRequest req)
{ {
var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id);
@ -423,7 +423,7 @@ namespace line_gestao_api.Controllers
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[Authorize(Roles = "admin")] [Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Delete(Guid id) public async Task<IActionResult> Delete(Guid id)
{ {
var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id);

View File

@ -78,6 +78,15 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Tenant>(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) // ✅ USER (Identity)
// ========================= // =========================
@ -271,6 +280,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
// ========================= // =========================
modelBuilder.Entity<AuditLog>(e => modelBuilder.Entity<AuditLog>(e =>
{ {
e.Property(x => x.MetadataJson).HasColumnType("jsonb");
e.Property(x => x.Action).HasMaxLength(20); e.Property(x => x.Action).HasMaxLength(20);
e.Property(x => x.Page).HasMaxLength(80); e.Property(x => x.Page).HasMaxLength(80);
e.Property(x => x.EntityName).HasMaxLength(120); e.Property(x => x.EntityName).HasMaxLength(120);
@ -282,8 +292,13 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
e.Property(x => x.RequestMethod).HasMaxLength(10); e.Property(x => x.RequestMethod).HasMaxLength(10);
e.Property(x => x.IpAddress).HasMaxLength(80); e.Property(x => x.IpAddress).HasMaxLength(80);
e.Property(x => x.ChangesJson).HasColumnType("jsonb"); 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.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.OccurredAtUtc);
e.HasIndex(x => x.Page); e.HasIndex(x => x.Page);
e.HasIndex(x => x.UserId); e.HasIndex(x => x.UserId);
@ -319,33 +334,33 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<MobileLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<MuregLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<BillingClient>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<UserData>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<UserData>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<VigenciaLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<VigenciaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<TrocaNumeroLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<TrocaNumeroLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ChipVirgemLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ControleRecebidoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<Notification>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoMacrophonyPlan>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoMacrophonyTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoVivoLineResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoVivoLineTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoVivoLineTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoClienteEspecial>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoClienteEspecial>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoPlanoContratoResumo>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoPlanoContratoResumo>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoPlanoContratoTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoPlanoContratoTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoLineTotais>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoLineTotais>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoGbDistribuicao>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoGbDistribuicao>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoGbDistribuicaoTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoGbDistribuicaoTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoReservaLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoReservaLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ResumoReservaTotal>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ResumoReservaTotal>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ParcelamentoLine>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ParcelamentoLine>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ParcelamentoMonthValue>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ParcelamentoMonthValue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<AuditLog>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ImportAuditRun>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ImportAuditIssue>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.TenantId != null && x.TenantId == _tenantProvider.TenantId); modelBuilder.Entity<ApplicationUser>().HasQueryFilter(x => _tenantProvider.HasGlobalViewAccess || (_tenantProvider.ActorTenantId != null && x.TenantId == _tenantProvider.ActorTenantId));
} }
public override int SaveChanges() public override int SaveChanges()
@ -364,12 +379,12 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
private void ApplyTenantIds() private void ApplyTenantIds()
{ {
if (_tenantProvider.TenantId == null) if (_tenantProvider.ActorTenantId == null)
{ {
return; return;
} }
var tenantId = _tenantProvider.TenantId.Value; var tenantId = _tenantProvider.ActorTenantId.Value;
foreach (var entry in ChangeTracker.Entries<ITenantEntity>().Where(e => e.State == EntityState.Added)) foreach (var entry in ChangeTracker.Entries<ITenantEntity>().Where(e => e.State == EntityState.Added))
{ {
if (entry.Entity.TenantId == Guid.Empty) if (entry.Entity.TenantId == Guid.Empty)

View File

@ -9,17 +9,14 @@ namespace line_gestao_api.Data;
public class SeedOptions public class SeedOptions
{ {
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
public string DefaultTenantName { get; set; } = "Default"; public string AdminMasterName { get; set; } = "System Admin";
public string AdminName { get; set; } = "Administrador"; public string? AdminMasterEmail { get; set; } = "sysadmin@linegestao.local";
public string AdminEmail { get; set; } = "admin@linegestao.local"; public string? AdminMasterPassword { get; set; } = "DevSysAdmin123!";
public string AdminPassword { get; set; } = "DevAdmin123!";
public bool ReapplyAdminCredentialsOnStartup { get; set; } = false; public bool ReapplyAdminCredentialsOnStartup { get; set; } = false;
} }
public static class SeedData public static class SeedData
{ {
public static readonly Guid DefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
public static async Task EnsureSeedDataAsync(IServiceProvider services) public static async Task EnsureSeedDataAsync(IServiceProvider services)
{ {
using var scope = services.CreateScope(); using var scope = services.CreateScope();
@ -29,224 +26,236 @@ public static class SeedData
var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>(); var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<SeedOptions>>().Value; var options = scope.ServiceProvider.GetRequiredService<IOptions<SeedOptions>>().Value;
if (db.Database.IsRelational())
{
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
}
else
{
await db.Database.EnsureCreatedAsync();
}
if (!options.Enabled) if (!options.Enabled)
{ {
return; return;
} }
var roles = new[] { "admin", "gestor", "operador", "leitura" }; var systemTenantId = SystemTenantConstants.SystemTenantId;
var roles = AppRoles.All;
foreach (var role in roles) foreach (var role in roles)
{ {
if (!await roleManager.RoleExistsAsync(role)) if (!await roleManager.RoleExistsAsync(role))
{ {
await roleManager.CreateAsync(new IdentityRole<Guid>(role)); var roleResult = await roleManager.CreateAsync(new IdentityRole<Guid>(role));
EnsureIdentitySucceeded(roleResult, $"Falha ao criar role '{role}'.");
} }
} }
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == DefaultTenantId); await MigrateLegacyRolesAsync(db, roleManager);
if (tenant == null)
var systemTenant = await db.Tenants.FirstOrDefaultAsync(t => t.Id == systemTenantId);
if (systemTenant == null)
{ {
tenant = new Tenant systemTenant = new Tenant
{ {
Id = DefaultTenantId, Id = systemTenantId,
Name = options.DefaultTenantName, NomeOficial = SystemTenantConstants.SystemTenantNomeOficial,
IsSystem = true,
Ativo = true,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
db.Tenants.Add(systemTenant);
db.Tenants.Add(tenant); }
await db.SaveChangesAsync(); 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 adminMasterEmail = (emailFromEnv ?? options.AdminMasterEmail ?? string.Empty).Trim().ToLowerInvariant();
var existingAdmin = await userManager.Users var adminMasterPassword = passwordFromEnv ?? options.AdminMasterPassword ?? string.Empty;
.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenant.Id);
if (existingAdmin == null) if (string.IsNullOrWhiteSpace(adminMasterEmail) || string.IsNullOrWhiteSpace(adminMasterPassword))
{ {
var adminUser = new ApplicationUser throw new InvalidOperationException(
"Credenciais do sysadmin ausentes. Defina SYSADMIN_EMAIL e SYSADMIN_PASSWORD (ou Seed:AdminMasterEmail/Seed:AdminMasterPassword).");
}
var normalizedEmail = userManager.NormalizeEmail(adminMasterEmail);
var previousTenant = tenantProvider.TenantId;
tenantProvider.SetTenantId(systemTenantId);
try
{ {
UserName = options.AdminEmail, var existingAdminMaster = await userManager.Users
Email = options.AdminEmail, .IgnoreQueryFilters()
Name = options.AdminName, .FirstOrDefaultAsync(u => u.TenantId == systemTenantId && u.NormalizedEmail == normalizedEmail);
TenantId = tenant.Id,
if (existingAdminMaster == null)
{
var adminMaster = new ApplicationUser
{
Name = options.AdminMasterName,
Email = adminMasterEmail,
UserName = adminMasterEmail,
TenantId = systemTenantId,
EmailConfirmed = true, EmailConfirmed = true,
IsActive = true, IsActive = true,
LockoutEnabled = true LockoutEnabled = true
}; };
var createResult = await userManager.CreateAsync(adminUser, options.AdminPassword); var createResult = await userManager.CreateAsync(adminMaster, adminMasterPassword);
if (createResult.Succeeded) EnsureIdentitySucceeded(createResult, "Falha ao criar usuário sysadmin.");
{
await userManager.AddToRoleAsync(adminUser, "admin");
}
}
else if (options.ReapplyAdminCredentialsOnStartup)
{
existingAdmin.Name = options.AdminName;
existingAdmin.Email = options.AdminEmail;
existingAdmin.UserName = options.AdminEmail;
existingAdmin.EmailConfirmed = true;
existingAdmin.IsActive = true;
existingAdmin.LockoutEnabled = true;
await userManager.SetLockoutEndDateAsync(existingAdmin, null); var addRoleResult = await userManager.AddToRoleAsync(adminMaster, SystemTenantConstants.SystemRole);
await userManager.ResetAccessFailedCountAsync(existingAdmin); EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial.");
await userManager.UpdateAsync(existingAdmin); }
else
{
existingAdminMaster.Name = options.AdminMasterName;
existingAdminMaster.Email = adminMasterEmail;
existingAdminMaster.UserName = adminMasterEmail;
existingAdminMaster.EmailConfirmed = true;
existingAdminMaster.IsActive = true;
existingAdminMaster.LockoutEnabled = true;
var resetToken = await userManager.GeneratePasswordResetTokenAsync(existingAdmin); var updateResult = await userManager.UpdateAsync(existingAdminMaster);
var resetPasswordResult = await userManager.ResetPasswordAsync(existingAdmin, resetToken, options.AdminPassword); 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) if (!resetPasswordResult.Succeeded)
{ {
var removePasswordResult = await userManager.RemovePasswordAsync(existingAdmin); var removePasswordResult = await userManager.RemovePasswordAsync(existingAdminMaster);
if (removePasswordResult.Succeeded) if (removePasswordResult.Succeeded)
{ {
await userManager.AddPasswordAsync(existingAdmin, options.AdminPassword); var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword);
EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin.");
} }
} else
if (!await userManager.IsInRoleAsync(existingAdmin, "admin"))
{ {
await userManager.AddToRoleAsync(existingAdmin, "admin"); var addPasswordResult = await userManager.AddPasswordAsync(existingAdminMaster, adminMasterPassword);
EnsureIdentitySucceeded(addPasswordResult, "Falha ao definir senha do sysadmin.");
}
} }
} }
tenantProvider.SetTenantId(null); if (!await userManager.IsInRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole))
} {
var addRoleResult = await userManager.AddToRoleAsync(existingAdminMaster, SystemTenantConstants.SystemRole);
private static async Task NormalizeLegacyTenantDataAsync(AppDbContext db, Guid defaultTenantId) EnsureIdentitySucceeded(addRoleResult, "Falha ao associar role sysadmin ao usuário inicial.");
}
}
}
finally
{
tenantProvider.SetTenantId(previousTenant);
}
}
private static void EnsureIdentitySucceeded(IdentityResult result, string message)
{
if (result.Succeeded)
{ {
if (defaultTenantId == Guid.Empty)
return; return;
}
await db.Users var details = string.Join("; ", result.Errors.Select(e => e.Description));
.IgnoreQueryFilters() throw new InvalidOperationException($"{message} Detalhes: {details}");
.Where(x => x.TenantId == Guid.Empty) }
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId));
await db.MobileLines private static async Task MigrateLegacyRolesAsync(AppDbContext db, RoleManager<IdentityRole<Guid>> roleManager)
.IgnoreQueryFilters() {
.Where(x => x.TenantId == Guid.Empty) await MigrateLegacyRoleAsync(db, roleManager, "admin_master", AppRoles.SysAdmin);
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); await MigrateLegacyRoleAsync(db, roleManager, "admin", AppRoles.SysAdmin);
await MigrateLegacyRoleAsync(db, roleManager, "leitura", AppRoles.Cliente);
await MigrateLegacyRoleAsync(db, roleManager, "operador", AppRoles.Cliente);
}
await db.MuregLines private static async Task MigrateLegacyRoleAsync(
.IgnoreQueryFilters() AppDbContext db,
.Where(x => x.TenantId == Guid.Empty) RoleManager<IdentityRole<Guid>> roleManager,
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); string legacyRole,
string newRole)
{
var legacyRoleId = await roleManager.Roles
.Where(r => r.Name == legacyRole)
.Select(r => (Guid?)r.Id)
.FirstOrDefaultAsync();
if (!legacyRoleId.HasValue)
{
return;
}
await db.BillingClients var newRoleId = await roleManager.Roles
.IgnoreQueryFilters() .Where(r => r.Name == newRole)
.Where(x => x.TenantId == Guid.Empty) .Select(r => (Guid?)r.Id)
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); .FirstOrDefaultAsync();
if (!newRoleId.HasValue)
{
return;
}
await db.UserDatas var legacyUserIds = await db.UserRoles
.IgnoreQueryFilters() .Where(ur => ur.RoleId == legacyRoleId.Value)
.Where(x => x.TenantId == Guid.Empty) .Select(ur => ur.UserId)
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); .Distinct()
.ToListAsync();
if (legacyUserIds.Count == 0)
{
return;
}
await db.VigenciaLines var alreadyInNewRole = await db.UserRoles
.IgnoreQueryFilters() .Where(ur => ur.RoleId == newRoleId.Value && legacyUserIds.Contains(ur.UserId))
.Where(x => x.TenantId == Guid.Empty) .Select(ur => ur.UserId)
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); .ToListAsync();
var existingSet = alreadyInNewRole.ToHashSet();
await db.TrocaNumeroLines foreach (var userId in legacyUserIds)
.IgnoreQueryFilters() {
.Where(x => x.TenantId == Guid.Empty) if (!existingSet.Contains(userId))
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); {
db.UserRoles.Add(new IdentityUserRole<Guid>
await db.ChipVirgemLines {
.IgnoreQueryFilters() UserId = userId,
.Where(x => x.TenantId == Guid.Empty) RoleId = newRoleId.Value
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); });
}
await db.ControleRecebidoLines }
.IgnoreQueryFilters()
.Where(x => x.TenantId == Guid.Empty) var legacyAssignments = await db.UserRoles
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); .Where(ur => ur.RoleId == legacyRoleId.Value)
.ToListAsync();
await db.Notifications if (legacyAssignments.Count > 0)
.IgnoreQueryFilters() {
.Where(x => x.TenantId == Guid.Empty) db.UserRoles.RemoveRange(legacyAssignments);
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); }
await db.ResumoMacrophonyPlans await db.SaveChangesAsync();
.IgnoreQueryFilters()
.Where(x => x.TenantId == Guid.Empty) var legacyRoleStillUsed = await db.UserRoles.AnyAsync(ur => ur.RoleId == legacyRoleId.Value);
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); if (!legacyRoleStillUsed)
{
await db.ResumoMacrophonyTotals var legacyRoleEntity = await roleManager.Roles.FirstOrDefaultAsync(r => r.Id == legacyRoleId.Value);
.IgnoreQueryFilters() if (legacyRoleEntity != null)
.Where(x => x.TenantId == Guid.Empty) {
.ExecuteUpdateAsync(setters => setters.SetProperty(x => x.TenantId, defaultTenantId)); await roleManager.DeleteAsync(legacyRoleEntity);
}
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));
} }
} }

23
Dtos/SystemTenantDtos.cs Normal file
View File

@ -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<string> 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<string> Roles { get; set; } = Array.Empty<string>();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -245,6 +245,12 @@ namespace line_gestao_api.Migrations
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("character varying(20)"); .HasColumnType("character varying(20)");
b.Property<Guid?>("ActorUserId")
.HasColumnType("uuid");
b.Property<Guid>("ActorTenantId")
.HasColumnType("uuid");
b.Property<string>("ChangesJson") b.Property<string>("ChangesJson")
.IsRequired() .IsRequired()
.HasColumnType("jsonb"); .HasColumnType("jsonb");
@ -274,6 +280,10 @@ namespace line_gestao_api.Migrations
.HasMaxLength(80) .HasMaxLength(80)
.HasColumnType("character varying(80)"); .HasColumnType("character varying(80)");
b.Property<string>("MetadataJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("RequestMethod") b.Property<string>("RequestMethod")
.HasMaxLength(10) .HasMaxLength(10)
.HasColumnType("character varying(10)"); .HasColumnType("character varying(10)");
@ -285,6 +295,9 @@ namespace line_gestao_api.Migrations
b.Property<Guid>("TenantId") b.Property<Guid>("TenantId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid>("TargetTenantId")
.HasColumnType("uuid");
b.Property<string>("UserEmail") b.Property<string>("UserEmail")
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
@ -298,6 +311,10 @@ namespace line_gestao_api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("ActorTenantId");
b.HasIndex("EntityName"); b.HasIndex("EntityName");
b.HasIndex("OccurredAtUtc"); b.HasIndex("OccurredAtUtc");
@ -306,6 +323,8 @@ namespace line_gestao_api.Migrations
b.HasIndex("TenantId"); b.HasIndex("TenantId");
b.HasIndex("TargetTenantId");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("AuditLogs"); b.ToTable("AuditLogs");
@ -1357,15 +1376,35 @@ namespace line_gestao_api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<bool>("Ativo")
.HasColumnType("boolean");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("Name") b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("NomeOficial")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SourceKey")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("SourceType")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("IsSystem", "Ativo");
b.HasIndex("SourceType", "SourceKey")
.IsUnique();
b.ToTable("Tenants"); b.ToTable("Tenants");
}); });

View File

@ -6,10 +6,16 @@ public class AuditLog : ITenantEntity
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
// Compatibilidade com histórico atual + filtro global.
public Guid TenantId { get; set; } 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; public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;
// Campos legados usados pela tela de histórico.
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public string? UserName { get; set; } public string? UserName { get; set; }
public string? UserEmail { get; set; } public string? UserEmail { get; set; }
@ -21,6 +27,7 @@ public class AuditLog : ITenantEntity
public string? EntityLabel { get; set; } public string? EntityLabel { get; set; }
public string ChangesJson { get; set; } = "[]"; public string ChangesJson { get; set; } = "[]";
public string MetadataJson { get; set; } = "{}";
public string? RequestPath { get; set; } public string? RequestPath { get; set; }
public string? RequestMethod { get; set; } public string? RequestMethod { get; set; }

View File

@ -3,6 +3,10 @@ namespace line_gestao_api.Models;
public class Tenant public class Tenant
{ {
public Guid Id { get; set; } = Guid.NewGuid(); 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; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }

View File

@ -1,4 +1,5 @@
using System.IO; using System.IO;
using System.Security.Claims;
using System.Text; using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using line_gestao_api.Data; using line_gestao_api.Data;
@ -91,6 +92,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, TenantProvider>(); builder.Services.AddScoped<ITenantProvider, TenantProvider>();
builder.Services.AddScoped<IAuditLogBuilder, AuditLogBuilder>(); builder.Services.AddScoped<IAuditLogBuilder, AuditLogBuilder>();
builder.Services.AddScoped<ISystemAuditService, SystemAuditService>();
builder.Services.AddScoped<IVigenciaNotificationSyncService, VigenciaNotificationSyncService>(); builder.Services.AddScoped<IVigenciaNotificationSyncService, VigenciaNotificationSyncService>();
builder.Services.AddScoped<ParcelamentosImportService>(); builder.Services.AddScoped<ParcelamentosImportService>();
builder.Services.AddScoped<GeralDashboardInsightsService>(); builder.Services.AddScoped<GeralDashboardInsightsService>();
@ -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 => builder.Services.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
@ -194,3 +202,7 @@ app.MapControllers();
app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run(); app.Run();
public partial class Program
{
}

10
Services/AppRoles.cs Normal file
View File

@ -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];
}

View File

@ -67,7 +67,7 @@ public class AuditLogBuilder : IAuditLogBuilder
public List<AuditLog> BuildAuditLogs(ChangeTracker changeTracker) public List<AuditLog> BuildAuditLogs(ChangeTracker changeTracker)
{ {
var tenantId = _tenantProvider.TenantId; var tenantId = _tenantProvider.ActorTenantId;
if (tenantId == null) if (tenantId == null)
{ {
return new List<AuditLog>(); return new List<AuditLog>();
@ -88,6 +88,12 @@ public class AuditLogBuilder : IAuditLogBuilder
return new List<AuditLog>(); return new List<AuditLog>();
} }
if (IsSystemRequest(requestPath))
{
// Endpoints system usam auditoria explicita com actor/target.
return new List<AuditLog>();
}
var logs = new List<AuditLog>(); var logs = new List<AuditLog>();
foreach (var entry in changeTracker.Entries()) foreach (var entry in changeTracker.Entries())
@ -109,6 +115,9 @@ public class AuditLogBuilder : IAuditLogBuilder
logs.Add(new AuditLog logs.Add(new AuditLog
{ {
TenantId = tenantId.Value, TenantId = tenantId.Value,
ActorUserId = userInfo.UserId,
ActorTenantId = tenantId.Value,
TargetTenantId = tenantId.Value,
OccurredAtUtc = DateTime.UtcNow, OccurredAtUtc = DateTime.UtcNow,
UserId = userInfo.UserId, UserId = userInfo.UserId,
UserName = userInfo.UserName, UserName = userInfo.UserName,
@ -119,6 +128,7 @@ public class AuditLogBuilder : IAuditLogBuilder
EntityId = BuildEntityId(entry), EntityId = BuildEntityId(entry),
EntityLabel = BuildEntityLabel(entry), EntityLabel = BuildEntityLabel(entry),
ChangesJson = JsonSerializer.Serialize(changes, JsonOptions), ChangesJson = JsonSerializer.Serialize(changes, JsonOptions),
MetadataJson = "{}",
RequestPath = requestPath, RequestPath = requestPath,
RequestMethod = requestMethod, RequestMethod = requestMethod,
IpAddress = ipAddress IpAddress = ipAddress
@ -138,6 +148,16 @@ public class AuditLogBuilder : IAuditLogBuilder
return requestPath.Contains("/import-excel", StringComparison.OrdinalIgnoreCase); 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) private static string ResolveAction(EntityState state)
=> state switch => state switch
{ {

View File

@ -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<byte> bytes = stackalloc byte[16];
hash.AsSpan(0, 16).CopyTo(bytes);
return new Guid(bytes);
}
}

View File

@ -0,0 +1,6 @@
namespace line_gestao_api.Services;
public interface ISystemAuditService
{
Task LogAsync(string action, Guid targetTenantId, object? metadata = null, CancellationToken cancellationToken = default);
}

View File

@ -2,6 +2,8 @@ namespace line_gestao_api.Services;
public interface ITenantProvider public interface ITenantProvider
{ {
Guid? ActorTenantId { get; }
Guid? TenantId { get; } Guid? TenantId { get; }
bool HasGlobalViewAccess { get; }
void SetTenantId(Guid? tenantId); void SetTenantId(Guid? tenantId);
} }

View File

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

View File

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

View File

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

View File

@ -13,8 +13,14 @@ public class TenantProvider : ITenantProvider
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
public Guid? ActorTenantId => TenantId;
public Guid? TenantId => CurrentTenant.Value ?? ResolveFromClaims(); public Guid? TenantId => CurrentTenant.Value ?? ResolveFromClaims();
public bool HasGlobalViewAccess =>
HasRole(AppRoles.SysAdmin) ||
HasRole(AppRoles.Gestor);
public void SetTenantId(Guid? tenantId) public void SetTenantId(Guid? tenantId)
{ {
CurrentTenant.Value = tenantId; CurrentTenant.Value = tenantId;
@ -27,4 +33,21 @@ public class TenantProvider : ITenantProvider
return Guid.TryParse(claim, out var tenantId) ? tenantId : null; 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));
}
} }

View File

@ -49,7 +49,10 @@ public class VigenciaNotificationBackgroundService : BackgroundService
return; 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) if (tenants.Count == 0)
{ {
_logger.LogWarning("Nenhum tenant encontrado para gerar notificações."); _logger.LogWarning("Nenhum tenant encontrado para gerar notificações.");

View File

@ -31,9 +31,8 @@
"Seed": { "Seed": {
"Enabled": true, "Enabled": true,
"ReapplyAdminCredentialsOnStartup": true, "ReapplyAdminCredentialsOnStartup": true,
"DefaultTenantName": "Default", "AdminMasterName": "Admin Master",
"AdminName": "Administrador", "AdminMasterEmail": "admin.master@linegestao.local",
"AdminEmail": "admin@linegestao.local", "AdminMasterPassword": "DevAdminMaster123!"
"AdminPassword": "DevAdmin123!"
} }
} }

View File

@ -11,9 +11,8 @@
"Seed": { "Seed": {
"Enabled": true, "Enabled": true,
"ReapplyAdminCredentialsOnStartup": false, "ReapplyAdminCredentialsOnStartup": false,
"DefaultTenantName": "Default", "AdminMasterName": "Admin Master",
"AdminName": "Administrador", "AdminMasterEmail": "admin.master@linegestao.local",
"AdminEmail": "admin@linegestao.local", "AdminMasterPassword": "YOUR_LOCAL_ADMIN_MASTER_SEED_PASSWORD"
"AdminPassword": "YOUR_LOCAL_ADMIN_SEED_PASSWORD"
} }
} }

View File

@ -31,9 +31,8 @@
"Seed": { "Seed": {
"Enabled": true, "Enabled": true,
"ReapplyAdminCredentialsOnStartup": true, "ReapplyAdminCredentialsOnStartup": true,
"DefaultTenantName": "Default", "AdminMasterName": "Admin Master",
"AdminName": "Administrador", "AdminMasterEmail": "admin.master@linegestao.local",
"AdminEmail": "admin@linegestao.local", "AdminMasterPassword": "DevAdminMaster123!"
"AdminPassword": "DevAdmin123!"
} }
} }

View File

@ -148,8 +148,12 @@ namespace line_gestao_api.Tests
TenantId = tenantId; TenantId = tenantId;
} }
public Guid? ActorTenantId => TenantId;
public Guid? TenantId { get; private set; } public Guid? TenantId { get; private set; }
public bool HasGlobalViewAccess => false;
public void SetTenantId(Guid? tenantId) public void SetTenantId(Guid? tenantId)
{ {
TenantId = tenantId; TenantId = tenantId;

View File

@ -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<List<string>>();
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<List<SystemTenantListItemDto>>();
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<string> { "cliente" }
};
var createResponse = await client.PostAsJsonAsync($"/api/system/tenants/{TenantAId}/users", request);
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<SystemTenantUserCreatedDto>();
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<List<string>>();
Assert.NotNull(clients);
Assert.Contains(TenantAClientName, clients!);
Assert.DoesNotContain(TenantBClientName, clients);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<AppDbContext>();
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<UserManager<ApplicationUser>>();
var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>();
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<string> 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<AuthResponse>();
Assert.NotNull(auth);
Assert.False(string.IsNullOrWhiteSpace(auth!.Token));
return auth.Token;
}
finally
{
client.DefaultRequestHeaders.Authorization = previousAuth;
}
}
private sealed class ApiFactory : WebApplicationFactory<Program>
{
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<string, string?>
{
["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<AppDbContext>();
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase(_databaseName);
});
});
}
}
}

View File

@ -8,6 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />

View File

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

View File

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