Skip to content

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:

PerguntaOnde
Diretórioquem eu posso referenciar?GET /users/directory · GetUserDirectoryQuery
Elegibilidadeeste 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.

UserIneligibilityReasonSituaçãoO que o operador faz
NotFoundid desconhecido, de outro tenant, usuário ou vínculo removidonada — o id não serve
LinkInactivevínculo desativado neste tenantreativar o vínculo aqui
AssignmentPeriodInactivevínculo válido, período não começou ou já terminourenovar o período
UserInactivevínculo bom, conta desativadareativar 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:

PerguntaPermissividade
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. CheckManyAsync faz 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.
  • HasRole só responde true para 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:

QueryAbre com qualquer uma de
leitura comumread: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 (como Include): id malformado é erro de validação, não um id silenciosamente ignorado. UserIds é a lista já convertida, sem duplicados; no máximo GetUserDirectoryQuery.MaxIds (50).
  • Search junto com Ids é AND. Page/PageSize nã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: ICrossTenantAuthorizedBy com manage:all-users — o mesmo de GetUsersAdminQuery. Sem admin:system nem manage: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; Search casa nome e e-mail (o admin já vê os endereços), com a mesma dobra de acentos do diretório. ExcludeTenantId tira quem tem vínculo não removido com o tenant (UserTenantMembership.LinkedTo — o conjunto que AssignUserToTenantCommand recusa). Contagem e página saem numa instrução só.
  • Hidratação (Ids, até 50): uma instrução, sem contagem; conta desativada vem com IsActive = false, excluída ou inexistente não vem; ExcludeTenantId nã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.

Released under the MIT License.