Users
CRUD de usuários locais vinculados a identidades Keycloak.
Perfis locais vinculados a uma identidade do Keycloak (idIdentity = sub).
Base path: /api/users · autenticação de usuário · header
Authorization: Bearer <token>.
| Método | Rota | Scope | Resource | Ação (role) | Sucesso |
|---|---|---|---|---|---|
POST | /api/users | create | resource:users | create | 201 |
GET | /api/users | read | resource:users | read | 200 |
GET | /api/users/:id | read | resource:users_id | read | 200 |
PUT | /api/users/:id | update | resource:users_id | update | 200 |
DELETE | /api/users/:id | delete | resource:users_id | delete | 204 |
POST | /service/users | serviço | — | — | 201 |
Controle de acesso por persona. Módulo
usersé exclusivo da personaglobal— nemtenant-admin(excluído ⏳),techniciannemviewerentram por padrão (só via role de exceçãousers:acao). Token sem role suficiente →403 NOT_ALLOWED. Isso inclui as rotas de vínculo com tenant (/api/users/:userId/tenants).
Criar usuário
POST /api/users — resolve a identidade no Keycloak pelo e-mail (que é o login) e a
vincula ao perfil local.
- Se já existe um usuário com aquele e-mail no Keycloak, o
subdele é reaproveitado comoidIdentity. - Se não existe, a identidade é criada no Keycloak (habilitada, sem senha, com as required
actions
UPDATE_PASSWORDeVERIFY_EMAIL) e um e-mail de definição de senha é enviado. Se o envio falhar (ex.: SMTP fora do ar), o usuário ainda é criado — a required action fica pendente e o e-mail pode ser reenviado pelo console do Keycloak.
O idIdentity não é enviado no corpo: ele é derivado do Keycloak.
Corpo
| Campo | Tipo | Obrigatório | Regras |
|---|---|---|---|
name | string | sim | mín. 1 caractere |
email | string | sim | e-mail válido, único |
federalTaxId | string | sim | CPF, 11 dígitos, com dígito verificador válido, único |
type | enum | sim | Admin | Coordinator | Technician | Cooperative | Global — Global dá acesso total (bypass de todo o RBAC); só quem já é Global acessa este endpoint |
jobTitle | string | não | cargo do usuário |
customerId | uuid | não | pode ser null; se informado, Customer existente (404 se não existir) |
curl -X POST http://localhost:3000/api/users \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Doe",
"email": "jane.doe@example.com",
"federalTaxId": "52998224725",
"type": "Admin",
"jobTitle": "Analista"
}'201 Created
{
"id": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
"idIdentity": "a3f5c9d2-keycloak-sub",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"federalTaxId": "52998224725",
"jobTitle": "Analista",
"type": "Admin",
"customerId": null,
"createdAt": "2026-07-10T12:00:00.000Z",
"updatedAt": "2026-07-10T12:00:00.000Z"
}Erros
| Status | code | Quando |
|---|---|---|
409 | USER_ALREADY_EXISTS | Já existe um usuário com esse email ou federalTaxId |
409 | IDENTITY_ALREADY_LINKED | A identidade encontrada no Keycloak já está vinculada a outro usuário local (idIdentity é único) |
404 | CUSTOMER_NOT_FOUND | O customerId informado não existe |
503 | IDENTITY_PROVIDER_UNAVAILABLE | Não foi possível consultar ou criar a identidade no Keycloak |
O backend fala com a Admin REST API do Keycloak usando o service account do client
(client_credentials). O client precisa das roles view-users e manage-users de
realm-management no realm — sem elas, toda criação de usuário responde 503.
Criar ou vincular usuário ao tenant (app-to-app)
POST /service/users — autenticado por ServiceAuthMiddleware (token de serviço,
client_credentials). O tenantId nunca vem no corpo: é sempre o do chamador,
resolvido do token.
Usado por outros serviços/apps parceiros para provisionar um usuário e já
vinculá-lo ao próprio tenant, verificando duplicidade por CPF (federalTaxId)
e e-mail:
- Se nem o CPF nem o e-mail existirem em nenhum usuário → cria o usuário
(resolvendo/criando a identidade no Keycloak, igual ao
POST /api/users) e já o vincula ao tenant do token. - Se o CPF e o e-mail já existirem e apontarem para o mesmo usuário → não cria nada, nem toca no Keycloak; apenas garante o vínculo com o tenant do token (idempotente).
- Se só um dos dois já existir, ou se apontarem para usuários diferentes →
409 USER_DATA_MISMATCH— nada é criado, atualizado ou vinculado.
Corpo
| Campo | Tipo | Obrigatório | Regras |
|---|---|---|---|
name | string | sim | mín. 1 caractere |
email | string | sim | e-mail válido |
federalTaxId | string | sim | CPF, 11 dígitos, com dígito verificador válido |
type | enum | sim | Admin | Coordinator | Technician | Cooperative — sem Global de propósito: provisionamento app-to-app nunca cria usuário com acesso total. Promover a Global é só pelo painel (ver Editar usuário). |
persona | enum | sim | tenant-admin | coordinator | technician | viewer — persona de acesso (RBAC) no tenant do token. Independente de type (que controla só o escopo de leitura por organization). Nunca global. Upsert: religar com persona diferente atualiza a persona do vínculo. |
jobTitle | string | não | cargo do usuário |
curl -X POST http://localhost:3000/service/users \
-H "Authorization: Bearer $SERVICE_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Doe",
"email": "jane.doe@example.com",
"federalTaxId": "52998224725",
"type": "Technician",
"persona": "technician",
"jobTitle": "Analista"
}'201 Created
{
"id": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
"idIdentity": "a3f5c9d2-keycloak-sub",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"type": "Technician",
"federalTaxId": "52998224725",
"jobTitle": "Analista",
"customerId": null,
"createdAt": "2026-07-10T12:00:00.000Z",
"updatedAt": "2026-07-10T12:00:00.000Z"
}Erros
| Status | code | Quando |
|---|---|---|
409 | USER_DATA_MISMATCH | federalTaxId e email não apontam para o mesmo usuário existente |
409 | IDENTITY_ALREADY_LINKED | A identidade encontrada no Keycloak já está vinculada a outro usuário local |
503 | IDENTITY_PROVIDER_UNAVAILABLE | Não foi possível consultar ou criar a identidade no Keycloak |
Listar usuários
GET /api/users — lista paginada.
Query params
| Param | Tipo | Padrão | Descrição |
|---|---|---|---|
page | number | 1 | inteiro, mín. 1 |
limit | number | 20 | inteiro, 1 a 100 |
name | string | — | filtro por nome |
email | string | — | filtro por email |
type | enum | — | Admin | Coordinator | Technician | Cooperative | Global |
customerId | string | — | filtro por customer |
curl "http://localhost:3000/api/users?page=1&limit=20&type=Admin" \
-H "Authorization: Bearer $ACCESS_TOKEN"200 OK
{
"data": [
{
"id": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
"idIdentity": "a3f5c9d2-keycloak-sub",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"federalTaxId": "52998224725",
"jobTitle": "Analista",
"type": "Admin",
"customerId": null,
"createdAt": "2026-07-10T12:00:00.000Z",
"updatedAt": "2026-07-10T12:00:00.000Z"
}
],
"total": 1,
"totalPages": 1,
"currentPage": 1
}Obter usuário por id
GET /api/users/:id — retorna um único usuário.
Erros
| Status | code | Quando |
|---|---|---|
404 | USER_NOT_FOUND | Usuário não existe |
Editar usuário
PUT /api/users/:id — atualização parcial (todos os campos opcionais).
| Campo | Tipo | Regras |
|---|---|---|
name | string | mín. 1 caractere |
email | string | e-mail válido |
type | enum | Admin | Coordinator | Technician | Cooperative | Global |
jobTitle | string | pode ser null (limpa o cargo) |
customerId | uuid | pode ser null; se informado, Customer existente |
federalTaxIdnão é editável (imutável após a criação).Promover a
Global(acesso total, bypass de todo o RBAC —RolesGuardePermissionGuard) é só{ "type": "Global" }aqui. Não requer nenhuma configuração no Keycloak. Só quem já éGlobalpode chamar este endpoint (Module.Usersé exclusivo dessa persona), então só umGlobalpromove outro.
Retorna 200 com o usuário atualizado.
Erros
| Status | code | Quando |
|---|---|---|
404 | USER_NOT_FOUND · CUSTOMER_NOT_FOUND | O usuário — ou o customerId informado — não existe |
409 | USER_ALREADY_EXISTS | Já existe outro usuário com esse email |
Remover usuário
DELETE /api/users/:id — soft delete. Retorna 204 No Content.
Erros
| Status | code | Quando |
|---|---|---|
404 | USER_NOT_FOUND | Usuário não existe |
Memberships (tenants do usuário)
Gerenciam a quais tenants o usuário pertence. É a origem do escopo de leitura: um usuário só enxerga cooperativas/documentos dos tenants dos quais é membro (ver também Organization Tenants).
| Método | Rota | Scope | Resource | Sucesso |
|---|---|---|---|---|
GET | /api/users/:userId/tenants | read | resource:users_id | 200 |
POST | /api/users/:userId/tenants | update | resource:users_id | 201 |
DELETE | /api/users/:userId/tenants/:tenantId | update | resource:users_id | 204 |
GET | /api/tenants/:tenantId/users | read | resource:tenants_id | 200 |
O lado inverso — listar os usuários de um tenant — é
GET /api/tenants/:tenantId/users(mesma relaçãouser_tenants, pela ótica do tenant). Vincular/desvincular continua sendo via as rotas de usuário acima.
Listar tenants do usuário
GET /api/users/:userId/tenants — retorna os tenants a que o usuário pertence, com a persona
atribuída em cada vínculo. 404 USER_NOT_FOUND se o usuário não existir.
{
"data": [
{
"id": "t1b2c3d4-e5f6-4789-a123-456789abcdef",
"customerId": "c1b2c3d4-e5f6-4789-a123-456789abcdef",
"appId": "a1b2c3d4-e5f6-4789-a123-456789abcdef",
"name": "Acme Tenant",
"active": true,
"persona": "technician"
}
]
}
personapode virnull— vínculo sem persona atribuída (legado, sem backfill, ou criado sem reatribuição posterior). Nesse estado o usuário toma403em qualquer request feita com aquele tenant ativo, até alguém religar comPOST /api/users/:userId/tenantsinformando a persona.
Vincular usuário a um tenant
POST /api/users/:userId/tenants — upsert: se o vínculo já existir, atualiza a persona
em vez de duplicar.
| Campo | Tipo | Obrigatório | Regras |
|---|---|---|---|
tenantId | uuid | sim | Tenant existente (404 TENANT_NOT_FOUND se não existir) |
persona | enum | sim | tenant-admin | coordinator | technician | viewer — persona do usuário nesse tenant. Nunca global. |
curl -X POST http://localhost:3000/api/users/$USER_ID/tenants \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tenantId": "t1b2c3d4-e5f6-4789-a123-456789abcdef",
"persona": "technician"
}'201 Created
{
"userId": "u1b2c3d4-e5f6-4789-a123-456789abcdef",
"tenantId": "t1b2c3d4-e5f6-4789-a123-456789abcdef"
}Erros
| Status | code | Quando |
|---|---|---|
404 | USER_NOT_FOUND · TENANT_NOT_FOUND | Usuário ou tenant não encontrado |
Desvincular usuário de um tenant
DELETE /api/users/:userId/tenants/:tenantId — idempotente. Retorna 204.
Erros
| Status | code | Quando |
|---|---|---|
404 | USER_NOT_FOUND | Usuário não existe |
Escopo de cooperativas do usuário no tenant
Dentro de cada tenant, restringe quais cooperativas aquele usuário específico enxerga —
um nível abaixo da membership (ver PLANO-RESTRICAO-ORGANIZACAO-POR-USUARIO.md). Exclusivo da
persona global; tenant-admin não configura isso, mesmo tratamento de users.
| Método | Rota | Scope | Resource | Sucesso |
|---|---|---|---|---|
GET | /api/users/:userId/tenants/:tenantId/organizations | read | resource:user-tenant-organizations | 200 |
PUT | /api/users/:userId/tenants/:tenantId/organizations | update | resource:user-tenant-organizations | 204 |
GET retorna o modo (ALL/RESTRICTED), os ids concedidos e effectiveScope:
{
"scope": "RESTRICTED",
"organizationIds": ["o1b2c3d4-e5f6-4789-a123-456789abcdef"],
"effectiveScope": "RESTRICTED"
}
effectiveScopeviraALL_BY_PERSONAquando o usuário-alvo étenant-adminnesse tenant — ele enxerga todas as cooperativas independente do que estiver salvo emscope/organizationIds. É um valor distinto deALL, pra UI deixar claro que a origem é a persona, não a configuração salva.
PUT substitui o conjunto inteiro — não é add/remove granular:
curl -X PUT http://localhost:3000/api/users/$USER_ID/tenants/$TENANT_ID/organizations \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scope": "RESTRICTED",
"organizationIds": ["o1b2c3d4-e5f6-4789-a123-456789abcdef"]
}'| Campo | Tipo | Obrigatório | Regras |
|---|---|---|---|
scope | enum | sim | ALL ou RESTRICTED |
organizationIds | uuid[] | só p/ RESTRICTED | Cada id precisa estar vinculado a esse tenant; lista vazia é permitida e bloqueia o usuário no tenant |
Retorna 204. Com scope: "ALL", organizationIds é ignorado — nenhum grant precisa existir
nesse modo.
Erros
| Status | code | Quando |
|---|---|---|
404 | USER_NOT_FOUND | Usuário não existe |
404 | USER_TENANT_NOT_FOUND | Usuário não é membro ativo desse tenant |
400 | ORGANIZATION_NOT_LINKED_TO_TENANT | Um ou mais organizationIds não estão vinculados a esse tenant |