Skip to content

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:

TrilhoRota baseQuem usaEscopo
Escopado (tenant)/users, /tenantsadmin do tenantsó o tenant do token / os vínculos do caller
Admin (global)/admin/users, /admin/tenantsoperador globaltoda 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ão scope singular. own deixou de existir (virou tenant), 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.defaultSignificaDeriva de (server-side)
globaladministra users de todos os tenantsadmin:system ou manage:all-users
tenantadministra users só no tenant do tokenqualquer read/create/update/delete:users
(domínio ausente)não administra usersnenhuma permissão de users
tenants.defaultSignificaDeriva de (server-side)
globalvê todos os tenants no trilho adminadmin:system ou manage:all-tenants
tenantvê 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:users tem users.default=tenant, mas GET /users responde 403 (ele cria, não lista). Um manage:all-users "puro" tem users.default=global e entra em /admin/users — e o /users, que pede read: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:

HTTPcodeQuandoO que a UI faz
409USER_EMAIL_UNAVAILABLECriar usuário com e-mail já existente na plataformaMensagem genérica; não revela que a conta existe. No trilho tenant, dispara um link request (§6).
403ROLE_ASSIGNMENT_ESCALATION_BLOCKEDTentar 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 endpointEsconda 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étodoRotaPermissãoRequestResponse
POST/userscreate:usersCreateUserInTenantDto201 UserDto
GET/usersread:usersquery (page,pageSize,search,sortBy,sortDirection,isActive,roleName,isLockedOut)PagedResult<UserDto>
GET/users/{id}read:users—UserDto (ou 404)
PUT/users/{id}update:usersUpdateUserDtoUserDto
DELETE/users/{id}delete:users—204
GET/users/{id}/rolesread:users—roles no tenant do token
GET/users/{id}/permissionsread:users—permissões efetivas no tenant
GET/users/{id}/tenantsread: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.
  • isActive no PUT afeta o vínculo com o tenant (ativa/desativa a associação), não o usuário global. DELETE desvincula do tenant (não apaga a conta global) — D4.
  • GET /users/{id}/tenants devolve só o vínculo do tenant do token (lista de 0 ou 1) — D9.
  • GET /users/{id}/roles resolve 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étodoRotaRequestResponse
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 paramsPagedResult<{ id, name, description, isActive? }> — seletor de pessoas do admin; ver admin-user-lookup-contract
POST/admin/usersCreateUserDto (com matriz tenants[])201 AdminUserEditDto
GET/admin/users/{id}—AdminUserEditDto
PUT/admin/users/{id}AdminUpdateUserDtoAdminUserEditDto
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.
  • DELETE aqui é 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).

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étodoRotaRequestResponse
GET/admin/tenant-link-requestsquerylista 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étodoRotaPermissãoResponse
GET/tenantsread:tenantsPagedResult<TenantDto> (só o escopo)
GET/tenants/{id}read:tenantsTenantSummaryDto (ou 404 fora do escopo)
GET/tenants/{id}/children, /hierarchy, /stats, /by-domain/{d}, /{id}/usersread:tenantsdentro do escopo
POST/tenantscreate:tenantscria tenant
POST/tenants/{id}/childrenmanage:child-tenantscria filho
PUT/tenants/{id}update:tenantsatualiza
PUT/DELETE/tenants/{id}/groupmanage:child-tenants(des)associa a grupo
POST/tenants/{id}/activate | /deactivateupdate:tenantsliga/desliga
DELETE/tenants/{id}delete:tenantsremove
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étodoRotaGateResponse
GET/admin/tenantsAny(admin:system, manage:all-tenants)PagedResult<TenantDto> (global)
GET/admin/tenants/{id}Any(admin:system, manage:all-tenants)TenantDto completo
GET/admin/tenants/{id}/rolesadmin:systemroles do tenant alvo
GET/PUT/admin/tenants/{id}/mfa-policyAny(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ãoO que abre
read/create/update/delete:userstrilho escopado de users (/users)
read/create/update/delete:roles, …:permissionsroles/permissões no tenant
read:tenantstrilho escopado de tenants (/tenants)
create/update/delete:tenants, manage:child-tenantsescrita/grupo de tenants
admin:systemwildcard — passa em qualquer gate (operador da plataforma / tenant seed)
manage:all-userstrilho admin de users (/admin/users + fila de link-requests), sem o wildcard
manage:all-tenantstrilho 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 com admin: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 ​

RoleNasce ondeConteúdo
SystemAdminsó no tenant plataformaadmin:system sozinho
Adminem todo tenant, inclusive o plataformaas 13 granulares
GroupAdminem todo tenant (inerte fora de um grupo)read:group-tenants, read:group-analytics, read:group-data, manage:child-tenants
Visitorem todo tenantnenhuma permissão

Duas consequências para quem parametriza perfis:

  1. Não existe mais uma role Admin que signifique "operador da plataforma". Cliente ou automação que identifique o operador pelo nome Admin precisa passar a olhar SystemAdmin. O admin@local.com do seed está em SystemAdmin.
  2. Compor roles com permissões de alcance de plataforma é limitado ao teto de menor privilégio. Anexar admin:system, manage:all-users ou manage:all-tenants a uma role sem possuí-la responde 403 ROLE_ASSIGNMENT_ESCALATION_BLOCKED, tanto em POST /roles/{id}/permissions/{permId} quanto em PUT /roles/{id}. Permissões que o próprio tenant cria para o negócio dele — product:create e 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 ​

  1. Login → recebe token. 2. GET /auth/session. 3. Roteia por capabilities (§2). 4. Renderiza só as áreas cujo scope ≠ none. Nada de if (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 Admin nasce 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) ​

  1. Rotear por capabilities, nunca por permissão crua.
  2. domínio ausente / tenant / group / global cobrem menu e navegação corretamente.
  3. 404 de recurso fora do escopo tratado como "não existe" (sem "acesso negado").
  4. 409 USER_EMAIL_UNAVAILABLE com mensagem genérica.
  5. 403 ROLE_ASSIGNMENT_ESCALATION_BLOCKED com mensagem clara.
  6. type do tenant: string no detalhe escopado, int na lista — normalizado.
  7. 401 → refresh + re-/session (scope pode ter mudado).
  8. Paginação via PagedResult<T> (items, totalCount, pageNumber, pageSize).

Released under the MIT License.