Appearance
Diretório e elegibilidade de usuários (backend)
Para o contrato HTTP consumido pelo frontend, veja user-directory-contract. Esta página é para a aplicação que embarca o GrydAuth.
O par
São duas metades da mesma pergunta, e a razão de existirem juntas é que elas precisam concordar:
| Pergunta | Onde | |
|---|---|---|
| Diretório | quem eu posso referenciar? | GET /users/directory · GetUserDirectoryQuery |
| Elegibilidade | este id que me mandaram pode ser referenciado? | IUserTenantEligibilityService · CheckUserEligibilityQuery |
Onde as duas discordam é onde o furo aparece. Um seletor que oferece alguém que a gravação recusa é um bug visível, chato e inofensivo. Uma gravação que aceita alguém que o seletor nunca ofereceu é um id de outro tenant, ou de quem saiu da empresa, persistido em silêncio — e só descoberto quando o fluxo chega a uma pessoa que não pode agir sobre ele.
Por isso as duas respondem da mesma definição, e há testes que fixam as duas direções: todo usuário elegível é um usuário que o diretório teria oferecido (a perigosa) e todo usuário que o diretório oferece é elegível (a visível). A regra é UserTenant.IsValid(); como um método de domínio não roda em SQL, o diretório lê a mesma regra soletrada para o banco em UserTenantMembership.InForceLinkToAny, e um teste de domínio mantém as duas grafias concordando em todo tipo de vínculo. Note que a administração (GET /users) continua listando quem está fora do período — o operador precisa enxergar o vínculo para renová-lo; é o diretório e a elegibilidade que são estritos, não a listagem.
O que "elegível" quer dizer
Elegível = vínculo ativo no tenant, dentro do período de vínculo, com a conta ativa e nada disso removido. É o UserTenant.IsValid() do domínio mais o estado do usuário.
UserIneligibilityReason | Situação | O que o operador faz |
|---|---|---|
NotFound | id desconhecido, de outro tenant, usuário ou vínculo removido | nada — o id não serve |
LinkInactive | vínculo desativado neste tenant | reativar o vínculo aqui |
AssignmentPeriodInactive | vínculo válido, período não começou ou já terminou | renovar o período |
UserInactive | vínculo bom, conta desativada | reativar a conta |
⚠️ NotFound é deliberadamente ambíguo. Ele funde quatro casos para que a verificação não vire um oráculo de "este id existe em algum lugar da plataforma?" para quem chuta ids. É a mesma regra anti-disclosure que faz os endpoints de usuário responderem 404 em vez de 403.
Não confunda com a isolação de tenant
ITenantContextService.ValidateUserTenantAccessAsync parece responder o mesmo e não responde: ela não olha UserTenant.IsActive nem User.IsActive. Um funcionário desligado passa nela.
E isso não é bug — é o que sustenta a administração. O TenantValidationBehavior usa essa checagem para todas as rotas marcadas com IRequireUserTenantIsolationById, e ActivateUserCommand é uma delas: se a isolação exigisse estar ativo, nenhum administrador conseguiria reativar ninguém — a pipeline responderia 404 antes do handler rodar.
São duas perguntas diferentes, e juntá-las quebra um dos dois lados:
| Pergunta | Permissividade | |
|---|---|---|
Isolação (ValidateUserTenantAccessAsync) | posso enxergar/administrar este usuário? | vínculo existe |
Elegibilidade (IUserTenantEligibilityService) | posso referenciá-lo num fluxo agora? | vínculo ativo e vigente + conta ativa |
Para decidir se um approverId pode ser gravado, use elegibilidade. Sempre.
Como usar
Sob uma requisição autenticada — o caminho padrão
csharp
using GrydAuth.Application.Features.Users.Queries;
using GrydAuth.Application.Features.Users.Services;
var result = await mediator.Send(new CheckUserEligibilityQuery(approverIds), ct);
var verdicts = result.Data!;CheckUserEligibilityQuery é ITenantAware: o tenant vem do token, injetado pela pipeline. Não há TenantId para o cliente informar — e é justamente essa ausência que impede um IDOR de tenant.
Validando níveis de aprovação, com papel:
csharp
var verdicts = (await mediator.Send(new CheckUserEligibilityQuery(
[.. levels.Select(l => l.ApproverId)]), ct))
.Data!
.ToDictionary(v => v.UserId);
var problems = levels
.Select(level =>
{
var verdict = verdicts[level.ApproverId];
return !verdict.IsEligible
? $"Nível {level.Order}: aprovador indisponível ({verdict.Reason})."
: !verdict.HasRole(level.ApproverRole)
? $"Nível {level.Order}: {verdict.FullName} não tem o papel {level.ApproverRole}."
: null;
})
.Where(problem => problem is not null)
.ToArray();
if (problems.Length > 0)
{
return Result.Failure(string.Join(" ", problems));
}Três coisas nesse trecho não são estilo:
- Uma chamada para todos os níveis.
CheckManyAsyncfaz uma query; validar em laço é N+1 e faz o operador descobrir um problema por submit. - Todo id perguntado recebe veredito, na ordem perguntada — o dicionário nunca falha no
[]. Um id ausente seria lido como "ok" pelo próximo join que alguém escrever. HasRolesó respondetruepara quem é elegível. "O aprovador tem o papel de aprovador?" tem uma resposta certa para quem não está no tenant, e é não.
Sem sessão — job, worker, importação
csharp
var verdict = await eligibility.CheckAsync(userId, tenantId, ct);IUserTenantEligibilityService recebe o tenant explicitamente, e é o único caminho para código que não roda sob uma requisição. Em troca, a responsabilidade passa a ser sua:
⚠️ Esse tenantId nunca pode vir do payload. Use o tenant que o seu próprio domínio já estabeleceu — o do registro que o job está processando. Um tenant vindo de fora troca um IDOR de usuário por um de tenant, que é estritamente pior.
Onde chamar
No command handler, não no controller. O fluxo pode nascer de importação, de job, de cópia de template; só o handler está em todos esses caminhos.
Permissão no caminho in-process
GET /users/directory é protegido nas duas portas. O [RequirePermission] do controller guarda a rota; a query declara IRequirePermission e um behavior da pipeline exige o mesmo conjunto — avaliado no tenant do token. Qual conjunto depende do que a query pede:
| Query | Abre com qualquer uma de |
|---|---|
| leitura comum | read:user-directory, read:user-email, read:users |
Include = ["email"] | read:user-email, read:users |
Isso importa para você: mediator.Send(new GetUserDirectoryQuery(...)) não é uma porta dos fundos. Sob uma sessão sem nenhuma das permissões ele responde MissingPermissionException (403 no HTTP), não o diretório; e pedir o e-mail com só read:user-directory é a mesma recusa, não uma resposta sem a coluna.
O e-mail
UserDirectoryEntry.Email vem preenchido só quando a query pediu (Include = ["email"]) e a sessão passou pelo gate. Nos demais casos o repositório projeta NULL — a coluna não é lida, o endereço não entra em memória. E o e-mail nunca é fallback de FullName: um usuário sem nome vem com FullName == "" mesmo quando Email está presente.
É campo de escopo de tenant. A validação recusa Include = ["email"] com RequestedScope = Group, e o mapper descarta o endereço numa entrada de grupo mesmo que uma chegue até ele: em escopo de grupo as linhas são de outras pessoas jurídicas, e read:user-email foi avaliada no tenant do token.
search continua casando só nome e sobrenome, com ou sem Include: quem pode ver o endereço não ganha o direito de confirmar um endereço por tentativa.
A elegibilidade não carrega gate próprio: ela responde sobre ids que a sua aplicação já tem no próprio agregado, e a decisão de quem pode disparar aquele fluxo é do seu handler.
Hidratação por id (Ids)
GetUserDirectoryQuery.Ids (no HTTP, ?ids=) troca a pergunta: em vez de "quem posso escolher agora?", "qual o nome deste id que já está gravado?". O handler chama IUserRepository.ResolveDirectoryEntriesAsync — um SELECT só, WHERE u."Id" = ANY(@ids) sobre a chave primária, com o rótulo de tenant calculado na mesma linha — e devolve tudo numa página.
O conjunto é mais largo que o da busca, de propósito: entra quem teve vínculo com um tenant do escopo, mesmo que o vínculo tenha expirado, sido desativado ou removido, e quem teve a conta desativada. Cada um desses vem com UserDirectoryEntry.IsReferenceable == false (isActive: false no JSON). Usuário excluído e quem nunca esteve no escopo continuam fora.
⚠️ Por isso a hidratação não é verificação de elegibilidade. Ela diz o nome de quem já está gravado; se um id pode ser gravado agora continua sendo pergunta do CheckUserEligibilityQuery. Um id que a hidratação devolveu com IsReferenceable == false é exatamente um que a elegibilidade recusa — as duas leem a mesma regra (UserTenantMembership.InForceLinkToAny + User.IsActive).
Detalhes que valem para quem manda a query in-process:
Idsé texto (comoInclude): id malformado é erro de validação, não um id silenciosamente ignorado.UserIdsé a lista já convertida, sem duplicados; no máximoGetUserDirectoryQuery.MaxIds(50).Searchjunto comIdsé AND.Page/PageSizenão se aplicam.- O gate, o escopo resolvido, o rate limit e a auditoria de grupo são os da busca — a query é a mesma.
Busca sem acento
search compara nome e termo depois de passar os dois por translate() do Postgres (PostgresTextFunctions), então joao acha João. É translate e não a extensão unaccent porque esta exige CREATE EXTENSION — privilégio que a role da aplicação nem sempre tem e que servidores gerenciados só liberam depois de o operador incluir a extensão na lista permitida; translate é do núcleo do Postgres e roda em qualquer servidor. A tabela cobre os acentos de nomes em português, espanhol e francês. Nenhum índice se perde: ILIKE '%…%' nunca usou índice.
Lookup de usuários do admin (GetAdminUserLookupQuery)
O diretório responde "quem está no escopo do chamador" e não serve ao admin da plataforma que procura quem está fora de um tenant para vinculá-lo. Para isso existe GetAdminUserLookupQuery (GET /admin/users/lookup), no trilho de administração:
- Gate:
ICrossTenantAuthorizedBycommanage:all-users— o mesmo deGetUsersAdminQuery. Semadmin:systemnemmanage:all-users, o pipeline recusa, venha a query por HTTP ou in-process. - Escopo: a plataforma (usuários não excluídos). Não há versão no trilho tenant.
- Busca: só contas ativas, por nome;
Searchcasa nome e e-mail (o admin já vê os endereços), com a mesma dobra de acentos do diretório.ExcludeTenantIdtira quem tem vínculo não removido com o tenant (UserTenantMembership.LinkedTo— o conjunto queAssignUserToTenantCommandrecusa). Contagem e página saem numa instrução só. - Hidratação (
Ids, até 50): uma instrução, sem contagem; conta desativada vem comIsActive = false, excluída ou inexistente não vem;ExcludeTenantIdnão se aplica. - Item:
AdminUserLookupItemDto { Id, Name, Description, IsActive? }— nome completo e e-mail.
Contrato para o front: admin-user-lookup-contract.
Registro
Nenhum. AddGrydAuthApplication() já registra IUserTenantEligibilityService e o handler da query.