Appearance
Guia frontend — Administração com escopo (Users & Tenants)
Épico 889. Reúne, para o time de frontend, tudo que mudou: modelo mental, contrato de sessão, endpoints, payloads de request/response, regras de negócio e fluxos ponta a ponta. Base URL:
/api/v1. Contrato de erro:application/problem+json(ver §3).
1. Modelo mental — dois trilhos
Toda administração (de usuários e de tenants) passou a ter dois trilhos paralelos:
| Trilho | Rota base | Quem usa | Escopo |
|---|---|---|---|
| Escopado (tenant) | /users, /tenants | admin do tenant | só o tenant do token / os vínculos do caller |
| Admin (global) | /admin/users, /admin/tenants | operador global | toda a plataforma |
Regra de ouro: o frontend nunca decide o trilho lendo permissões cruas. Ele lê o bloco capabilities do /session (§2) e roteia por ele. Isso é decisão de arquitetura (D5, "Tell, Don't Ask"): reimplementar a regra de autorização em TypeScript geraria divergência garantida.
Ver também: session-capabilities-contract, tenant-read-scoping-contract, v1-http-contract-migration.
2. Ponto de partida: GET /api/v1/auth/session
Autenticado. Descreve o caller no tenant do token — depois de um switch-tenant, chame de novo.
Response 200:
json
{
"userId": "3f1c…",
"email": "user@acme.com",
"roles": ["Admin"],
"permissions": ["read:users", "create:users", "update:users", "delete:users", "read:tenants"],
"capabilities": {
"users": { "scopes": ["global"], "default": "global" },
"tenants": { "scopes": ["tenant"], "default": "tenant" }
}
}Mudou na F4 (Épico 925). O bloco é um mapa por domínio com
scopes[]+default, nãoscopesingular.owndeixou de existir (viroutenant), e um domínio que a sessão não alcança é ausente do mapa — não vem com"none". Detalhe completo em session-capabilities-contract.md.
Como rotear
ts
const { capabilities } = (await api.get("/auth/session")).data;
const usersBase =
capabilities.users?.default === "global" ? "/admin/users"
: capabilities.users?.default === "tenant" ? "/users"
: null; // ausente → esconder a área de usuários
const tenantsBase =
capabilities.tenants?.default === "global" ? "/admin/tenants" : "/tenants";Semântica
users.default | Significa | Deriva de (server-side) |
|---|---|---|
global | administra users de todos os tenants | admin:system ou manage:all-users |
tenant | administra users só no tenant do token | qualquer read/create/update/delete:users |
| (domínio ausente) | não administra users | nenhuma permissão de users |
tenants.default | Significa | Deriva de (server-side) |
|---|---|---|
global | vê todos os tenants no trilho admin | admin:system ou manage:all-tenants |
tenant | vê só os próprios vínculos (piso) | qualquer outro caso |
⚠️ Capability diz QUAL trilho. Sobre o trilho
global, ela agora garante mais do que isso. Desde o ADR 0007 (A1) são duas afirmações diferentes, e vale distinguir:
- Alcance cross-tenant — a capability garante.
default=global⟺ o trilho/admin/*daquele domínio é alcançável. A permissão é a única fonte de verdade do alcance; não existe segunda condição (antes existia: uma coluna por usuário que o servidor exigia e que o cálculo da capability não enxergava, então a sessão abria a área e o backend respondia 403). A equivalência é verificada nas duas direções, por teste, para cada caller — se ela deixar de valer, a suíte quebra antes do deploy.- Gate de leitura de cada endpoint — a capability NÃO garante. Cada rota mantém a própria permissão. Ex.: um caller só com
create:userstemusers.default=tenant, masGET /usersresponde 403 (ele cria, não lista). Ummanage:all-users"puro" temusers.default=globale entra em/admin/users— e o/users, que pederead:users, segue 403.Em uma frase: a capability diz onde bater, e para o trilho global diz também que você entra; ela nunca diz que toda rota daquele trilho responde 200. Roteie pelo
default; trate o gate de cada endpoint normalmente.
3. Contrato de erro (ProblemDetails)
Erros são application/problem+json com status, title, detail e extensions padronizadas (code, errors, traceId). Códigos que o frontend deve tratar por nome:
| HTTP | code | Quando | O que a UI faz |
|---|---|---|---|
409 | USER_EMAIL_UNAVAILABLE | Criar usuário com e-mail já existente na plataforma | Mensagem genérica; não revela que a conta existe. No trilho tenant, dispara um link request (§6). |
403 | ROLE_ASSIGNMENT_ESCALATION_BLOCKED | Tentar conceder role/permissão além das permissões efetivas do caller | "Você não pode conceder mais do que possui." |
404 | (anti-disclosure) | Acessar recurso fora do escopo (outro tenant) | Trate como "não existe" — é indistinguível de um id inexistente, de propósito. |
401 | — | Token inválido/expirado ou invalidado (ver §7) | Refaz login/refresh. |
403 | — | Sem a permissão do endpoint | Esconda a ação (idealmente já escondida pela capability). |
Nunca deduza "existe mas não posso ver" de um 404 — o backend usa 404 anti-enumeração justamente para não vazar existência entre tenants.
4. Usuários — trilho escopado (/users)
Opera apenas no tenant do token e sempre sobre o vínculo (UserTenant), nunca sobre o usuário global (D4). Item de leitura: UserDto.
UserDto (response)
json
{
"id": "guid",
"email": "user@acme.com",
"firstName": "Ada", "lastName": "Lovelace",
"isActive": true,
"isLockedOut": false, "lockoutExpiresAt": null,
"lastLoginAt": "2026-07-20T10:00:00Z",
"profilePictureUrl": null,
"phoneNumber": null,
"createdAt": "…", "updatedAt": null,
"roles": [ { "id": "guid", "name": "Editor", "permissions": [ … ] } ],
"directPermissions": [ { "id": "guid", "name": "read:users" } ],
"appIds": ["BDD_TEST_APP"],
"tenants": [ … ]
}Endpoints
| Método | Rota | Permissão | Request | Response |
|---|---|---|---|---|
| POST | /users | create:users | CreateUserInTenantDto | 201 UserDto |
| GET | /users | read:users | query (page,pageSize,search,sortBy,sortDirection,isActive,roleName,isLockedOut) | PagedResult<UserDto> |
| GET | /users/{id} | read:users | — | UserDto (ou 404) |
| PUT | /users/{id} | update:users | UpdateUserDto | UserDto |
| DELETE | /users/{id} | delete:users | — | 204 |
| GET | /users/{id}/roles | read:users | — | roles no tenant do token |
| GET | /users/{id}/permissions | read:users | — | permissões efetivas no tenant |
| GET | /users/{id}/tenants | read:users | — | List<UserTenantDto> 0..1 (só o vínculo do tenant do token) |
| POST/DELETE | /users/{userId}/roles/{roleId} | update:users | — | 200/204 |
| POST/DELETE | /users/{userId}/permissions/{permissionId} | update:users | — | 200/204 |
CreateUserInTenantDto (request):
json
{ "email": "new@acme.com", "password": "…", "firstName": "…", "lastName": "…", "phoneNumber": "…", "appIds": ["…"] }Sem matriz de tenant e sem
isActive: o novo usuário é sempre vinculado ao tenant do token e o vínculo nasce ativo. O admin de tenant não cria usuários em outros tenants.
UpdateUserDto (request): { firstName?, lastName?, phoneNumber?, appIds?, isActive? }
Regras de negócio (trilho escopado)
- Isolamento real:
GET/PUT/DELETE /users/{id}de um usuário fora do tenant do token → 404 (anti-disclosure), não 403. isActiveno PUT afeta o vínculo com o tenant (ativa/desativa a associação), não o usuário global.DELETEdesvincula do tenant (não apaga a conta global) — D4.GET /users/{id}/tenantsdevolve só o vínculo do tenant do token (lista de 0 ou 1) — D9.GET /users/{id}/rolesresolve pelos papéis no tenant do token (corrige IDOR → 404).- Anti-escalação (POST role/permissão): o conjunto concedido tem de ser subconjunto das permissões efetivas do caller; senão
403 ROLE_ASSIGNMENT_ESCALATION_BLOCKED.admin:systemé o override natural. - E-mail já existente:
409 USER_EMAIL_UNAVAILABLE+ registra um link request (§6).
5. Usuários — trilho admin global (/admin/users)
Gate: admin:system ou manage:all-users — e nada além disso. O trilho é cross-tenant e a permissão responde sozinha pelo alcance (ADR 0007, A1): não há segunda concessão para o operador lembrar de fazer depois. Item: AdminUserEditDto (com matriz de tenants).
AdminUserEditDto (response)
json
{
"id": "guid", "email": "…", "firstName": "…", "lastName": "…", "phoneNumber": "…",
"isActive": true, "isLockedOut": false, "lockoutExpiresAt": null,
"createdAt": "…", "updatedAt": null,
"appIds": ["…"],
"tenants": [ { "tenantId": "guid", "roleIds": ["guid"], "isDefault": true } ]
}Endpoints
| Método | Rota | Request | Response |
|---|---|---|---|
| GET | /admin/users (query tenantId? opcional) | query params (os mesmos de /users, inclusive isLockedOut) | PagedResult<AdminUserEditDto> |
| GET | /admin/users/lookup (search, page, pageSize, ids, excludeTenantId) | query params | PagedResult<{ id, name, description, isActive? }> — seletor de pessoas do admin; ver admin-user-lookup-contract |
| POST | /admin/users | CreateUserDto (com matriz tenants[]) | 201 AdminUserEditDto |
| GET | /admin/users/{id} | — | AdminUserEditDto |
| PUT | /admin/users/{id} | AdminUpdateUserDto | AdminUserEditDto |
| DELETE | /admin/users/{id} | — | 204 (delete global) |
| POST/DELETE | /admin/users/{userId}/roles/{roleId} | — | 200/204 |
| POST/DELETE | /admin/users/{userId}/permissions/{permissionId} | — | 200/204 |
| GET | /admin/users/{id}/tenants | — | matriz completa de vínculos (List<UserTenantDto>) |
| POST | /admin/users/{id}/tenants | { tenantId, roleIds[], isDefault } | 201 AdminUserEditDto (acrescenta um vínculo) |
CreateUserDto (request):
json
{
"email": "…", "password": "…", "firstName": "…", "lastName": "…", "phoneNumber": "…",
"isActive": true,
"appIds": ["…"],
"tenants": [ { "tenantId": "guid", "roleIds": ["guid"], "isDefault": true } ]
}AdminUpdateUserDto (request): UpdateUserDto + tenants? — matriz de vínculos. null = não mexe; [] = remove todos os vínculos.
Acrescentar um vínculo sem regravar a matriz — POST /admin/users/{id}/tenants: o corpo é um item de tenants[] (roleIds obrigatório, 1 a 50). Devolve 201 com o AdminUserEditDto já atualizado. Erros: 404 USER_NOT_FOUND / TENANT_NOT_FOUND / ROLE_NOT_FOUND (role de outro tenant é indistinguível de inexistente), 403 ROLE_ASSIGNMENT_ESCALATION_BLOCKED, 409USER_ALREADY_ASSIGNED (já vinculado — nada muda, os papéis não são mesclados), 400TENANT_CAPACITY_REACHED. Aceita usuário inativo. Quem tem só manage:all-users concede apenas roles cujas permissões ele mesmo tem no tenant do próprio token; admin:system concede qualquer uma. Contrato completo em docs/modules/auth/crud/endpoints.md.
Diferenças-chave vs. o trilho escopado
- Opera global (qualquer tenant), com matriz de tenants explícita nos payloads.
DELETEaqui é delete do usuário global (não desvínculo).- Também abre a fila de tenant-link-requests (§6) para
manage:all-users/admin:system. - Não dá acesso a
/admin/tenants(isso émanage:all-tenants).
6. Fila de pedidos de vínculo — /admin/tenant-link-requests (D8)
Quando um admin de tenant tenta criar um usuário cujo e-mail já existe, o backend não vaza a existência: devolve 409 USER_EMAIL_UNAVAILABLE e cria um pedido de vínculo pendente. Um ator privilegiado aprova/rejeita.
Gate: admin:system (global), manage:all-users (global) ou manage:child-tenants (GroupAdmin, escopo do grupo). O escopo do resultado segue o caller: global vê tudo; GroupAdmin vê só os pedidos dos tenants do seu grupo.
| Método | Rota | Request | Response |
|---|---|---|---|
| GET | /admin/tenant-link-requests | query | lista de TenantLinkRequestDto |
| GET | /admin/tenant-link-requests/{id} | — | TenantLinkRequestDto |
| POST | /admin/tenant-link-requests/{id}/approve | — | vincula o usuário existente ao tenant |
| POST | /admin/tenant-link-requests/{id}/reject | { "reason": "opcional" } | marca como rejeitado |
TenantLinkRequestDto (response):
json
{
"id": "guid",
"tenant": { "id": "guid", "name": "Acme" },
"targetEmail": "existing@acme.com",
"existingUser": { "id": "guid", "fullName": "Ada Lovelace" },
"requestedBy": { "id": "guid", "fullName": "Tenant Admin" },
"requestedAt": "…", "decidedAt": null, "decidedBy": null,
"rejectReason": null,
"expiresAt": "…",
"status": "Pending" // string: Pending | Approved | Rejected | Expired
}Regras: status como string (v5). Pedido pendente vencido é projetado como Expired. Aprovar duas vezes / aprovar-e-rejeitar → só um vence (concorrência otimística). existingUser só aparece aqui (visão privilegiada) — nunca no 409 devolvido ao requester.
7. Tenants — trilho escopado (/tenants) (D11)
read:tenants, caller-scoped: o escopo é a lista de vínculos do caller (não o tenant do token) — todos os tenants onde tem UserTenant ativo mais os filhos ativos de grupos onde é GroupAdmin. Caller sem vínculos ⇒ lista vazia (não é erro).
| Método | Rota | Permissão | Response |
|---|---|---|---|
| GET | /tenants | read:tenants | PagedResult<TenantDto> (só o escopo) |
| GET | /tenants/{id} | read:tenants | TenantSummaryDto (ou 404 fora do escopo) |
| GET | /tenants/{id}/children, /hierarchy, /stats, /by-domain/{d}, /{id}/users | read:tenants | dentro do escopo |
| POST | /tenants | create:tenants | cria tenant |
| POST | /tenants/{id}/children | manage:child-tenants | cria filho |
| PUT | /tenants/{id} | update:tenants | atualiza |
| PUT/DELETE | /tenants/{id}/group | manage:child-tenants | (des)associa a grupo |
| POST | /tenants/{id}/activate | /deactivate | update:tenants | liga/desliga |
| DELETE | /tenants/{id} | delete:tenants | remove |
| POST/DELETE | /tenants/{id}/users[...] | update:tenants | (des)vincula usuários |
TenantSummaryDto (detalhe escopado — só identidade/hierarquia):
json
{
"id": "guid", "name": "Acme",
"type": "Company", // STRING ("Company" | "Group")
"isActive": true,
"parentTenantId": "guid|null",
"parentTenantName": "…|null",
"isGroupTenant": false
}⚠️ Assimetria de
type: no detalhe (TenantSummaryDto)typeé string; na lista (TenantDto, abaixo)typeé int (compat). Normalize no cliente.O detalhe escopado não traz campos operacionais (
settings/MFA,maxUsers,activeUserCount,domain,description, timestamps) — esses são exclusivos do trilho admin.
8. Tenants — trilho admin global (/admin/tenants)
Gate: admin:system ou manage:all-tenants (só os dois reads globais). Contrato completo TenantDto, todos os tenants.
| Método | Rota | Gate | Response |
|---|---|---|---|
| GET | /admin/tenants | Any(admin:system, manage:all-tenants) | PagedResult<TenantDto> (global) |
| GET | /admin/tenants/{id} | Any(admin:system, manage:all-tenants) | TenantDto completo |
| GET | /admin/tenants/{id}/roles | admin:system | roles do tenant alvo |
| GET/PUT | /admin/tenants/{id}/mfa-policy | Any(admin:system, update:tenants) | política de MFA |
TenantDto completo (response):
json
{
"id": "guid", "name": "Acme", "description": "…", "isActive": true, "domain": "acme.com",
"maxUsers": 100, "activeUserCount": 42, "createdAt": "…", "updatedAt": null,
"type": 0, // INT (0=Company, …) — diferente do summary!
"parentTenantId": "guid|null", "parentTenantName": "…|null", "isGroupTenant": false
}A escrita de tenants continua no
/tenants(TenantsController), com as permissões da §7 — não há espelho de escrita em/admin/tenants. O trilho admin de tenants é só leitura global.
9. Permissões relevantes
| Permissão | O que abre |
|---|---|
read/create/update/delete:users | trilho escopado de users (/users) |
read/create/update/delete:roles, …:permissions | roles/permissões no tenant |
read:tenants | trilho escopado de tenants (/tenants) |
create/update/delete:tenants, manage:child-tenants | escrita/grupo de tenants |
admin:system | wildcard — passa em qualquer gate (operador da plataforma / tenant seed) |
manage:all-users | trilho admin de users (/admin/users + fila de link-requests), sem o wildcard |
manage:all-tenants | trilho admin (leitura) de tenants (/admin/tenants), sem o wildcard |
O Admin de um tenant (role Admin) nasce com as 13 granulares (CRUD de users/roles/permissions
read:tenants) — e não comadmin:system. Consequência: um Admin de tenant não acessa/admin/*, nem MFA policy, nem SSO enterprise. Isso é intencional (least privilege).
Desde o ADR 0007 isso vale em todo tenant, inclusive no plataforma: quem opera a plataforma está na role SystemAdmin, que só nasce lá e carrega admin:system sozinha. O nome da role passou a dizer o que ela é.
Como um perfil é parametrizado hoje
| Role | Nasce onde | Conteúdo |
|---|---|---|
SystemAdmin | só no tenant plataforma | admin:system sozinho |
Admin | em todo tenant, inclusive o plataforma | as 13 granulares |
GroupAdmin | em todo tenant (inerte fora de um grupo) | read:group-tenants, read:group-analytics, read:group-data, manage:child-tenants |
Visitor | em todo tenant | nenhuma permissão |
Duas consequências para quem parametriza perfis:
- Não existe mais uma role
Adminque signifique "operador da plataforma". Cliente ou automação que identifique o operador pelo nomeAdminprecisa passar a olharSystemAdmin. Oadmin@local.comdo seed está emSystemAdmin. - Compor roles com permissões de alcance de plataforma é limitado ao teto de menor privilégio. Anexar
admin:system,manage:all-usersoumanage:all-tenantsa uma role sem possuí-la responde 403ROLE_ASSIGNMENT_ESCALATION_BLOCKED, tanto emPOST /roles/{id}/permissions/{permId}quanto emPUT /roles/{id}. Permissões que o próprio tenant cria para o negócio dele —product:createe afins — não têm teto: o Admin do tenant as compõe livremente.
Revogar uma permissão de uma role de sistema do catálogo (as quatro acima) responde 409 CONFLICT — a plataforma provisiona essas roles e depende do conteúdo delas. Roles criadas pelo tenant seguem editáveis normalmente.
10. Fluxos ponta a ponta
10.1 Login → montar a navegação
- Login → recebe token. 2.
GET /auth/session. 3. Roteia porcapabilities(§2). 4. Renderiza só as áreas cujo scope ≠none. Nada deif (permissions.includes(...)).
10.2 Admin de tenant cria um usuário
POST /users com CreateUserInTenantDto →
- 201
UserDto: criado e vinculado ao tenant do token. - 409
USER_EMAIL_UNAVAILABLE: e-mail já existe → mostre mensagem genérica. Nos bastidores foi criado um link request que um admin global/GroupAdmin vai aprovar (§6). A UI do tenant admin não vê esse pedido (não tem escopo) — o usuário aparecerá quando o pedido for aprovado.
10.3 Admin global vê e edita qualquer usuário
capabilities.users?.default === "global" → use /admin/users. Liste (PagedResult<AdminUserEditDto>), edite com matriz de tenants (AdminUpdateUserDto), gerencie a fila /admin/tenant-link-requests.
Para a ação em lote "Adicionar a uma organização", chame POST /admin/users/{id}/tenants uma vez por usuário selecionado (Promise.allSettled): 201 = vinculado, 409 USER_ALREADY_ASSIGNED = já tinha acesso, o resto = falha (mostre o detail). Não use o PUT com tenants para isso — ele substitui a matriz inteira e apaga alterações feitas por outra pessoa no meio.
10.4 Admin global (só tenants) audita a plataforma
capabilities.tenants?.default === "global" (via manage:all-tenants) → use /admin/tenants para listar e detalhar todos os tenants (contrato completo). Escrita continua em /tenants (precisa das permissões de escrita).
10.5 Sessão invalidada de repente (401)
Eventos que afetam autorização (mudança de role, de permissão ou de vínculo) sobem o token version e invalidam os tokens do usuário. Trate 401 como "refaça login/refresh"; o próximo /session reflete o novo scope automaticamente — se o caller perder o alcance global, ele passa a ver users.default=tenant / tenants.default=tenant e a UI troca de trilho sozinha, sem release de frontend.
A despromoção do Admin de tenant (F8/D10) é hoje um fato do catálogo, não um evento: a role
Adminnasce com as 13 granulares em todo tenant. A migração de startup que reparava bancos provisionados antes disso foi removida — não há mais banco anterior a ela, e a base nasce correta pelo seed.
11. Checklist de QA (frontend)
- Rotear por
capabilities, nunca por permissão crua. - domínio ausente /
tenant/group/globalcobrem menu e navegação corretamente. 404de recurso fora do escopo tratado como "não existe" (sem "acesso negado").409 USER_EMAIL_UNAVAILABLEcom mensagem genérica.403 ROLE_ASSIGNMENT_ESCALATION_BLOCKEDcom mensagem clara.typedo tenant: string no detalhe escopado, int na lista — normalizado.401→ refresh + re-/session(scope pode ter mudado).- Paginação via
PagedResult<T>(items,totalCount,pageNumber,pageSize).