Skip to content

Contrato do diretório de usuários — GET /users/directory ​

Base URL /api/v1. Erros em application/problem+json. Escopo de grupo: group-scope-contract · ADR 0006. Backend que embarca o GrydAuth (e a validação de elegibilidade que acompanha este seletor): user-directory-and-eligibility.

1. Para que serve ​

Preencher seletores de pessoas: autocomplete de responsável, campo de atribuição, menção, qualquer tela que precise dizer "quem" e devolver um id.

Não é a listagem de usuários. GET /users continua sendo a tela de administração — ela devolve o agregado inteiro (papéis, permissões aninhadas, app ids) e exige read:users. Usar aquela rota para alimentar um combobox significa entregar a matriz de autorização de todo mundo do tenant a qualquer tela que só queria um nome, e refazer esse trabalho a cada tecla digitada.

GET /usersGET /users/directory
Públicoadministrador do tenantqualquer membro
Permissãoread:usersread:user-directory (ou read:users)
ItemUserDto completo{ id, fullName } — e email sob read:user-email, a pedido
Escopotenant do tokentenant do token ou grupo (?scope=group)
Ordenaçãoconfigurávelpor nome, fixa

E no admin? O diretório responde "quem está no meu escopo" e continua sendo o seletor de pessoas das telas de negócio. O seletor do admin da plataforma — por exemplo "adicionar usuários ao tenant", que procura quem ainda não está no tenant, em toda a plataforma — usa GET /admin/users/lookup (admin-user-lookup-contract), sob admin:system ou manage:all-users. Por definição o diretório não lista quem está fora do escopo.

2. Requisição ​

GET /api/v1/users/directory?search=ana&page=1&pageSize=20&scope=tenant&include=email
ParâmetroDefaultRegras
searchausentecasa nome e sobrenome, sem distinguir maiúsculas nem acentos (joao acha João, conceicao acha Conceição). Máx. 128 caracteres. Ausente ⇒ lista o escopo desde o começo (é o que o seletor mostra antes de o usuário digitar)
page11-based
pageSize20máximo 50 — acima disso é 400
scopetenanttenant ou group; ver §5
includeausentecampos opcionais por item. Vocabulário: email. Aceita repetido (include=a&include=b) ou separado por vírgula. Nome desconhecido ⇒ 400. Ver §3.1
idsausentehidratação: resolve estes ids em vez de buscar. Repetido ou separado por vírgula; duplicados ignorados; vazio = ausente. Máximo 50 ids distintos — acima disso, ou com um id malformado, 400. Ver §3.2

⚠️ search não casa e-mail, nem mesmo com include=email. É deliberado: quem pode ver o endereço ainda não pode confirmar um endereço por tentativa, uma tecla por vez. Se a sua tela precisa buscar por e-mail, ela é uma tela de administração e vive em GET /users.

Não existe sortBy, sortDirection nem includeDeleted. A ordem é por nome; na busca, usuários desativados, sem vínculo ativo, fora do período de vínculo (ainda não começou ou já terminou) ou removidos nunca aparecem. É exatamente o conjunto que a validação de elegibilidade aceita na gravação: o que o seletor oferece, o submit aceita — e vice-versa. A única exceção é a hidratação por ids (§3.2), que devolve quem já esteve no escopo marcado com isActive: false, justamente para a tela não oferecê-lo. Usuário excluído não aparece em nenhuma combinação de parâmetros.

3. Resposta ​

Envelope PagedResult<T> de sempre, com o bloco scope do ADR 0006:

jsonc
{
  "items": [
    { "id": "3f1c…", "fullName": "Ana Silva" },
    { "id": "9a72…", "fullName": "Bruno Costa" }
  ],
  "totalCount": 42, "pageNumber": 1, "pageSize": 20,
  "totalPages": 3, "hasNext": true, "hasPrevious": false,
  "scope": { "mode": "tenant", "groupId": null, "groupName": null, "tenantCount": 1 }
}

fullName pode vir vazio ​

firstName e lastName são opcionais no cadastro (um login federado cujo provedor não devolveu claims de perfil é o caminho comum). Quando não há nome nenhum, fullName vem "" — nunca o e-mail, nunca um placeholder. Renderize o seu próprio texto de fallback; o backend não devolve uma string que ele não teria como localizar. A pessoa continua na lista: ela é selecionável, e escondê-la a tornaria silenciosamente inatribuível.

3.1 O e-mail não vem por padrão — e vem a pedido, para quem pode ​

Por padrão dois homônimos são indistinguíveis em escopo de tenant. Foi uma decisão consciente: o endereço é o identificador de login, e publicar a lista completa de endereços do tenant para qualquer membro é matéria-prima de phishing interno. Mas se esse risco compensa é uma pergunta sobre o tenant — numa empresa o endereço está na cópia de todo e-mail; num marketplace, não — e por isso o framework não responde por ele: o administrador do tenant responde, concedendo read:user-email às roles que precisam desempatar homônimos.

Quem tem a permissão pede a coluna:

GET /api/v1/users/directory?search=joão&include=email
jsonc
{
  "items": [
    { "id": "3f1c…", "fullName": "João Antonio da Silva", "email": "joao.silva@acme.com" },
    { "id": "9a72…", "fullName": "João Antonio da Silva", "email": "joao.antonio@acme.com" }
  ]
}

As regras:

  • É opt-in na requisição. Ter a permissão não muda a resposta de uma leitura comum; a mesma requisição tem uma forma só, não importa quem a envia. Sem include=email, nunca sai @.
  • Sem a permissão, include=email é 403 — não uma resposta mais magra. Um seletor que pediu a coluna e não a recebeu em silêncio nunca saberia por que os dois Joãos continuam iguais.
  • Só em escopo de tenant. Em scope=group as linhas são de empresas irmãs, cujos administradores não concederam nada a este caller; include=email com scope=group é 400. Ali, a coluna de organização (§5) separa homônimos de empresas diferentes.
  • Não pergunte à permissão, pergunte à sessão. O bloco userDirectory de capabilities anuncia fields: ["email"] quando esta sessão pode pedir (§5). Mostre a coluna e envie o include só quando o anúncio existir.

3.2 Hidratação — ?ids= ​

A busca responde "quem eu posso escolher agora?". Quando a tela já tem o id — formulário em edição, célula de tabela, chip de filtro, seleção múltipla preenchida — a pergunta é outra: "qual o nome deste id que já está gravado?". É para isso que existe ids:

GET /api/v1/users/directory?ids=3f1c…,9a72…,c0de…
jsonc
{
  "items": [
    { "id": "3f1c…", "fullName": "Ana Silva" },
    { "id": "9a72…", "fullName": "Bruno Costa", "isActive": false }
  ],
  "totalCount": 2, "pageNumber": 1, "pageSize": 3,
  "totalPages": 1, "hasNext": false, "hasPrevious": false,
  "scope": { "mode": "tenant", "groupId": null, "groupName": null, "tenantCount": 1 }
}

As regras:

  • Uma chamada para a tela inteira. Junte os ids pedidos por todos os componentes numa janela curta (o getMany do react-admin) e faça uma requisição; mais de 50 ids distintos, quebre em lotes de 50. Uma lista de 50 requisições com a coluna "Solicitante" é uma chamada, não 50 — e cada chamada conta no mesmo orçamento de rate limit da busca (§6).
  • Uma página só. Todo id encontrado volta na página 1; page e pageSize não se aplicam e o pageSize da resposta é a quantidade de ids distintos pedidos.
  • search junto com ids é AND (a interseção). O seletor não precisa mandar os dois; se mandar, nenhum dos dois é ignorado em silêncio.
  • Id ausente da resposta = "Registro indisponível". Id de outro tenant, inexistente ou de usuário excluído simplesmente não vem — status 200, sem 404 e sem nada que diferencie "existe em outro lugar" de "não existe".
  • Diferente da busca, a hidratação devolve quem não é mais escolhível: conta desativada, ou vínculo com um tenant do escopo expirado, desativado ou removido. Uma requisição antiga cujo solicitante saiu da empresa continua mostrando o nome dele. Esses itens vêm com isActive: false — mostre o nome, mas não ofereça a pessoa de novo (desabilite a opção, marque o chip). O campo não vem nos demais itens (ausente ⇒ ativo), então o payload da busca não mudou. "Vínculo que já existiu" é com um tenant do escopo: usuário que só passou por outro tenant continua ausente.
  • Mesmas portas da busca: mesmas permissões (include=email com ids exige read:user-email, e com scope=group é 400), mesmo escopo (scope=group resolve ids do grupo inteiro, com tenantId/tenantName no item e a mesma auditoria de grupo) e mesmo rate limit.
ts
// o front junta os ids da tela e hidrata em lotes de 50
const chunks = chunk([...new Set(ids)], 50);
const pages = await Promise.all(chunks.map(c =>
  api.get("/users/directory", { params: { ids: c.join(","), scope } })));
const byId = new Map(pages.flatMap(p => p.items).map(u => [u.id, u]));
// byId.get(id) === undefined ⇒ "Registro indisponível"
// byId.get(id)?.isActive === false ⇒ mostra o nome, não oferece de novo

4. Permissão ​

PermissãoO que abre
read:user-directoryeste endpoint, e só ele
read:user-emaileste endpoint e a coluna email (include=email). Implica o diretório: um endereço sem nome não é nada que um seletor mostre
read:userstambém abre os dois (quem lê o agregado inteiro não é recusado nem nos nomes nem no endereço)

read:user-directory e read:user-email são novas e nascem concedidas às roles Admin e GroupAdmin de todo tenant. Para que um usuário comum use o seletor, o administrador do tenant precisa conceder read:user-directory à role dele; para que ele veja o endereço, read:user-email. Nenhuma das duas vai para a role Visitor por padrão (ver §7).

Como sempre, o frontend não lê permissions: lê capabilities (§5).

5. Escopo de grupo ​

O domínio se chama userDirectory no bloco capabilities do GET /auth/session:

jsonc
{
  "capabilities": {
    "userDirectory": { "scopes": ["tenant", "group"], "default": "tenant", "fields": ["email"] }
  }
}
ts
const caps = session.capabilities["userDirectory"];
const canGroup = caps?.scopes.includes("group") ?? false;
const canEmail = caps?.fields?.includes("email") ?? false;
// domínio ausente do mapa ⇒ esconda o seletor de pessoas inteiro
// `fields` ausente ⇒ a sessão só pode pedir a forma básica; não envie `include`

fields lista os campos opcionais que esta sessão pode pedir via include=. Ausente quando não há nenhum — não vem []. Hoje o vocabulário é email; um valor que você não conhece é um campo que a sua versão não sabe renderizar, ignore-o.

  • scope=group lê o grupo do tenant do token — o tenant do grupo mais as filiais ativas. O conjunto é resolvido no servidor; o cliente nunca envia ids de tenant.
  • Exige read:group-data, avaliado no tenant do grupo (G4). Um Admin de uma filial não passa a ver as irmãs — e a sessão não anuncia group para ele: o contributor lê o mesmo conjunto, no mesmo tenant, que o endpoint avalia. scopes contém group ⟺ ?scope=group responde 200.
  • Cada item passa a carregar tenantId e tenantName, e a coluna de organização vira obrigatória na sua UI — em escopo consolidado o dado tem que dizer de onde veio.
  • Uma pessoa vinculada a várias empresas do grupo aparece uma vez, rotulada com o tenant do token quando ela pertence a ele e, caso contrário, com o primeiro por nome de tenant.
  • Toda leitura em scope=group entra na trilha de auditoria de grupo (G3): ler as pessoas de empresas irmãs é acesso entre pessoas jurídicas distintas.
jsonc
{
  "items": [
    { "id": "3f1c…", "fullName": "Ana Silva", "tenantId": "…", "tenantName": "Acme SP" }
  ],
  "scope": { "mode": "group", "groupId": "…", "groupName": "Grupo Acme", "tenantCount": 7 }
}

⚠️ default é tenant mesmo para quem pode ler o grupo. Um seletor que se alargasse sozinho para o grupo inteiro no dia em que alguém ganhou a permissão mudaria o que toda tela mostra sem ninguém ter pedido.

6. Erros ​

SituaçãoHTTPcode
sem token401—
sem read:user-directory nem read:users403—
scope fora do vocabulário400SCOPE_INVALID
scope=group sem read:group-data no tenant do grupo403SCOPE_NOT_AUTHORISED — a capability não anuncia group nesse caso
scope=group num tenant fora de hierarquia422SCOPE_GROUP_UNAVAILABLE — idem
include=email sem read:user-email nem read:users403—
include=email com scope=group; include com nome desconhecido400validação
pageSize > 50, page < 1, search > 128400validação
mais de 50 ids distintos; ids com valor que não é um id400validação
orçamento de requisições estourado429AUTH_RATE_LIMIT_EXCEEDED

Ramifique sempre pelo code; title e detail são localizados.

429 — debounce não é opcional ​

O endpoint tem orçamento próprio, por usuário (não por IP — um escritório atrás de um NAT são muitos seletores). É folgado para digitação humana e apertado para um laço enumerando as pessoas do tenant. Faça debounce de ~300 ms e cancele a requisição anterior; respeite o header Retry-After.

7. Rollout ​

  • As migrations criam as duas permissões em todos os tenants existentes e as concedem às roles Admin e GroupAdmin de cada um. Nada a fazer manualmente para esses.
  • A role Visitor não recebe nenhuma das duas. Dar o diretório — ou o endereço — a todo mundo por padrão é uma decisão de política com alcance no tenant inteiro, e ela é do administrador do tenant — não do framework. Enquanto read:user-directory não for concedida, um usuário comum recebe 403 e o domínio userDirectory fica ausente do capabilities dele: esconda o seletor, não mostre um campo que vai falhar. Enquanto read:user-email não for concedida, fields fica ausente: esconda a coluna e não envie include.

Released under the MIT License.