Appearance
Contrato do diretório de usuários — GET /users/directory
Base URL
/api/v1. Erros emapplication/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 /users | GET /users/directory | |
|---|---|---|
| Público | administrador do tenant | qualquer membro |
| Permissão | read:users | read:user-directory (ou read:users) |
| Item | UserDto completo | { id, fullName } — e email sob read:user-email, a pedido |
| Escopo | tenant do token | tenant do token ou grupo (?scope=group) |
| Ordenação | configurável | por 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âmetro | Default | Regras |
|---|---|---|
search | ausente | casa 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) |
page | 1 | 1-based |
pageSize | 20 | máximo 50 — acima disso é 400 |
scope | tenant | tenant ou group; ver §5 |
include | ausente | campos opcionais por item. Vocabulário: email. Aceita repetido (include=a&include=b) ou separado por vírgula. Nome desconhecido ⇒ 400. Ver §3.1 |
ids | ausente | hidrataçã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=emailjsonc
{
"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=groupas linhas são de empresas irmãs, cujos administradores não concederam nada a este caller;include=emailcomscope=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
userDirectorydecapabilitiesanunciafields: ["email"]quando esta sessão pode pedir (§5). Mostre a coluna e envie oincludesó 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
getManydo 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;
pageepageSizenão se aplicam e opageSizeda resposta é a quantidade de ids distintos pedidos. searchjunto comidsé 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=emailcomidsexigeread:user-email, e comscope=groupé400), mesmo escopo (scope=groupresolve ids do grupo inteiro, comtenantId/tenantNameno 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 novo4. Permissão
| Permissão | O que abre |
|---|---|
read:user-directory | este endpoint, e só ele |
read:user-email | este endpoint e a coluna email (include=email). Implica o diretório: um endereço sem nome não é nada que um seletor mostre |
read:users | també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=grouplê 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 anunciagrouppara ele: o contributor lê o mesmo conjunto, no mesmo tenant, que o endpoint avalia.scopescontémgroup⟺?scope=groupresponde 200. - Cada item passa a carregar
tenantIdetenantName, 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=groupentra 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ção | HTTP | code |
|---|---|---|
| sem token | 401 | — |
sem read:user-directory nem read:users | 403 | — |
scope fora do vocabulário | 400 | SCOPE_INVALID |
scope=group sem read:group-data no tenant do grupo | 403 | SCOPE_NOT_AUTHORISED — a capability não anuncia group nesse caso |
scope=group num tenant fora de hierarquia | 422 | SCOPE_GROUP_UNAVAILABLE — idem |
include=email sem read:user-email nem read:users | 403 | — |
include=email com scope=group; include com nome desconhecido | 400 | validação |
pageSize > 50, page < 1, search > 128 | 400 | validação |
mais de 50 ids distintos; ids com valor que não é um id | 400 | validação |
| orçamento de requisições estourado | 429 | AUTH_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
AdmineGroupAdminde cada um. Nada a fazer manualmente para esses. - A role
Visitornã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. Enquantoread:user-directorynão for concedida, um usuário comum recebe 403 e o domíniouserDirectoryfica ausente docapabilitiesdele: esconda o seletor, não mostre um campo que vai falhar. Enquantoread:user-emailnão for concedida,fieldsfica ausente: esconda a coluna e não envieinclude.